Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ef4a1b1cf | ||
|
|
dda210c219 | ||
|
|
0b2f0b76c4 | ||
|
|
97f0219dfd | ||
|
|
3a4c440354 | ||
|
|
4f8c373121 | ||
|
|
428548c390 |
@@ -13,7 +13,11 @@ from typing import Any
|
||||
|
||||
from agent.channel.h5.wecom_close import wecom_auto_close_script
|
||||
from agent.schema.air_route_options import (
|
||||
option_airline,
|
||||
option_endpoint_name,
|
||||
option_fee_lines,
|
||||
option_flight_date,
|
||||
option_schedule,
|
||||
option_total,
|
||||
parse_air_options,
|
||||
route_codes,
|
||||
@@ -197,7 +201,11 @@ def expired_air_route_page() -> str:
|
||||
def render_air_route_page(payload: dict[str, Any]) -> str:
|
||||
"""
|
||||
渲染选线路页。payload 来自 token:quote / facts / work_order_no。
|
||||
|
||||
cabin_pick 为真:没填报价日期,按舱位六项展示(含总价,不含费用明细)。
|
||||
"""
|
||||
if payload.get("cabin_pick"):
|
||||
return _render_cabin_page(payload)
|
||||
quote = dict(payload.get("quote") or {})
|
||||
facts = dict(payload.get("facts") or {})
|
||||
wo = html.escape(str(payload.get("work_order_no") or ""))
|
||||
@@ -235,6 +243,73 @@ def render_air_route_ok() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _render_cabin_page(payload: dict[str, Any]) -> str:
|
||||
"""没填日期的舱位列表。顺序以 token 里已排好的 airOptions 为准。"""
|
||||
quote = dict(payload.get("quote") or {})
|
||||
facts = dict(payload.get("facts") or {})
|
||||
wo = html.escape(str(payload.get("work_order_no") or ""))
|
||||
options = parse_air_options(quote)
|
||||
rows = [_cabin_card(i, option, quote, facts) for i, option in enumerate(options)]
|
||||
count = len(options)
|
||||
body = (
|
||||
"<div class='wrap'>"
|
||||
"<header class='hero'>"
|
||||
f"<p class='wo'>{wo}</p>"
|
||||
"<h1>选择舱位</h1>"
|
||||
f"<p class='lead'>共 {count} 条舱位,点选一条后确定。价格是 TMS 返回的总价。</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 _cabin_field(label: str, value: str) -> str:
|
||||
"""舱位一项。空值留空,不写横杠,避免把没返回的班期显示成占位符。"""
|
||||
shown = html.escape(value or "")
|
||||
return (
|
||||
"<li><span class='n'>"
|
||||
+ html.escape(label)
|
||||
+ "</span><span class='a'>"
|
||||
+ shown
|
||||
+ "</span></li>"
|
||||
)
|
||||
|
||||
|
||||
def _cabin_card(index: int, option: dict[str, Any], quote: dict[str, Any], facts: dict[str, str]) -> str:
|
||||
"""
|
||||
一张舱位:起运地、目的地、航班日期、班期、航司、总价。
|
||||
|
||||
不展示费用明细。班期用 TMS 原文。
|
||||
"""
|
||||
src = dict(option or {})
|
||||
fields = (
|
||||
("起运地", option_endpoint_name(src, "origin", facts)),
|
||||
("目的地", option_endpoint_name(src, "destination", facts)),
|
||||
("航班日期", option_flight_date(src)),
|
||||
("班期", option_schedule(src)),
|
||||
("航司", option_airline(src)),
|
||||
)
|
||||
field_html = "<ul class='fees'>" + "".join(_cabin_field(n, v) for n, v in fields) + "</ul>"
|
||||
total = html.escape(option_total(src, quote))
|
||||
return (
|
||||
"<label class='opt'>"
|
||||
f"<input type='radio' name='index' value='{index}' required />"
|
||||
"<div class='head'><span class='tick' aria-hidden='true'></span>"
|
||||
"<div class='route'><strong>舱位</strong></div></div>"
|
||||
f"{field_html}"
|
||||
f"<p class='total'><span>总价</span><b>{total}</b></p>"
|
||||
"</label>"
|
||||
)
|
||||
|
||||
|
||||
def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], facts: dict[str, str]) -> str:
|
||||
"""一张线路卡:航线、TMS 费用、总价。没有费用行就留空说明,不编造。"""
|
||||
src = dict(option or {})
|
||||
|
||||
@@ -33,15 +33,21 @@ def issue_air_route_token(
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
facts: dict[str, str] | None = None,
|
||||
cabin_pick: bool = False,
|
||||
ttl_seconds: int = AIR_ROUTE_TTL_SECONDS,
|
||||
) -> str:
|
||||
"""签发选线路 token。副作用:写 Redis 或内存。"""
|
||||
"""
|
||||
签发选线路 token。副作用:写 Redis 或内存。
|
||||
|
||||
cabin_pick=True:没填报价日期,页面按舱位六项展示,选定后不再查价。
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"sender_id": sender_id,
|
||||
"work_order_no": work_order_no,
|
||||
"quote": quote,
|
||||
"facts": dict(facts or {}),
|
||||
"cabin_pick": bool(cabin_pick),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
陆运选线路 H5 页。
|
||||
|
||||
本文件职责:按完整线路列出各段成本与合计,提交所选下标。
|
||||
本文件职责:按完整线路列出各段车型、成本与合计,提交所选下标。
|
||||
样式对齐空运选线路页。禁止 URL userid;禁止本页查 TMS。
|
||||
"""
|
||||
|
||||
@@ -64,19 +64,40 @@ def render_land_route_ok() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _segment_vehicle(leg: dict[str, Any] | None) -> str:
|
||||
"""每段车型用 TMS 的 vehicleType 原文。没有台数,空值显示 -。"""
|
||||
src = dict(leg or {})
|
||||
raw = str(
|
||||
src.get("vehicleType") or src.get("vehicle_type") or src.get("truckType") or ""
|
||||
).strip()
|
||||
if raw.lower() in {"none", "null"}:
|
||||
raw = ""
|
||||
return raw or "-"
|
||||
|
||||
|
||||
def _route_card(index: int, route: dict[str, Any], quote: dict[str, Any] | None = None) -> str:
|
||||
"""一张线路:航线、各段路段+TMS 费目、合计。"""
|
||||
"""一张线路:航线、各段路段、车型、TMS 费目、合计。"""
|
||||
line = html.escape(route_line(route))
|
||||
blocks = land_leg_fee_blocks_for_route(quote, route)
|
||||
shown_legs = [
|
||||
leg
|
||||
for leg in list(route.get("legs") or [])
|
||||
if str(leg.get("origin") or "").strip() or str(leg.get("destination") or "").strip()
|
||||
]
|
||||
etas = {i: d for i, (_, d) in enumerate(option_eta_bits(route), 1)}
|
||||
fee_bits: list[str] = []
|
||||
items = ""
|
||||
if blocks:
|
||||
for i, (title, fees, eta) in enumerate(blocks, 1):
|
||||
vehicle = _segment_vehicle(shown_legs[i - 1] if i - 1 < len(shown_legs) else None)
|
||||
items += (
|
||||
f"<li class='leg'><span class='n'>{html.escape(title)}</span>"
|
||||
"<span></span></li>"
|
||||
)
|
||||
items += (
|
||||
f"<li><span class='n'>车型 {html.escape(vehicle)}</span>"
|
||||
"<span></span></li>"
|
||||
)
|
||||
for fee in fees:
|
||||
amount = _money(fee.get("amount"), str(fee.get("currency") or "").strip())
|
||||
name = str(fee.get("name") or "").strip()
|
||||
|
||||
@@ -50,6 +50,10 @@ def _reask_one(row: dict[str, Any], *, settings: Settings, ledger: Any = None) -
|
||||
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
|
||||
if not no:
|
||||
return False
|
||||
# 进程/双 Worker 防重:同一工单同一天只发一轮(主账 next_followup 清不掉时曾连发 WO024)
|
||||
if _already_sent_reask(no, settings):
|
||||
logger.warning("协商再问去重跳过 wo=%s", no)
|
||||
return False
|
||||
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
|
||||
sales = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip()
|
||||
name = str(row.get("sales_name") or row.get("salesName") or "").strip() or "销售"
|
||||
@@ -69,6 +73,7 @@ def _reask_one(row: dict[str, Any], *, settings: Settings, ledger: Any = None) -
|
||||
if not ok:
|
||||
logger.warning("协商再问原群失败且不补发私聊 wo=%s chat=%s", no, chat_id)
|
||||
return False
|
||||
_mark_sent_reask(no, settings)
|
||||
_start_deal_wait(ledger, no)
|
||||
return True
|
||||
if not sales:
|
||||
@@ -87,10 +92,47 @@ def _reask_one(row: dict[str, Any], *, settings: Settings, ledger: Any = None) -
|
||||
if not ok:
|
||||
logger.warning("协商再问私聊入队失败 wo=%s info=%s", no, info)
|
||||
return False
|
||||
_mark_sent_reask(no, settings)
|
||||
_start_deal_wait(ledger, no)
|
||||
return True
|
||||
|
||||
|
||||
def _already_sent_reask(work_order_no: str, settings: Settings) -> bool:
|
||||
"""
|
||||
跨进程去重:Redis SET NX。
|
||||
|
||||
返回 True=今天已发过应跳过;False=抢到发送权。
|
||||
主账 next_followup 若因 updateById 清不掉 null,没有这层会每分钟连发。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from agent.policy.deal_followup import SHANGHAI
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
from agent.redis_coord.idempotency import try_acquire
|
||||
|
||||
day = datetime.now(SHANGHAI).strftime("%Y-%m-%d")
|
||||
try:
|
||||
runtime = get_redis_runtime(settings)
|
||||
client = getattr(runtime, "client", None)
|
||||
if client is None:
|
||||
return False
|
||||
first = try_acquire(
|
||||
client,
|
||||
scope="deal_reask",
|
||||
idempotency_key=f"{work_order_no}:{day}",
|
||||
ttl_seconds=86400,
|
||||
)
|
||||
return not first
|
||||
except Exception:
|
||||
logger.exception("协商再问 Redis 去重失败 wo=%s", work_order_no)
|
||||
return False
|
||||
|
||||
|
||||
def _mark_sent_reask(work_order_no: str, settings: Settings) -> None:
|
||||
"""兼容旧调用点;去重键已在发送前 SET NX。"""
|
||||
_ = (work_order_no, settings)
|
||||
|
||||
|
||||
def _start_deal_wait(ledger: Any, work_order_no: str) -> None:
|
||||
"""再问发出后重新盯成交跟进。ledger 可空。"""
|
||||
from agent.policy.event_exception import WAIT_DEAL, start_wait
|
||||
|
||||
@@ -330,12 +330,32 @@ class HttpLedger:
|
||||
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "patch_lost_reason_failed"}
|
||||
|
||||
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
||||
"""认领到期协商再问,主账会清空再问时刻防重发。"""
|
||||
"""
|
||||
认领到期协商再问。
|
||||
|
||||
主账应用 UpdateWrapper 清空 next_followup_at;若旧包仍用 updateById
|
||||
写不进 null,这里对每单再调 clearNextFollowup 强制清掉,避免每分钟连发。
|
||||
"""
|
||||
out = self._post("/inquiry/agent/ticket/claimDueNegotiate", {})
|
||||
rows = out.get("tickets") or out.get("result") or []
|
||||
if isinstance(rows, list):
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
return []
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
claimed: list[dict[str, Any]] = []
|
||||
for raw in rows:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
row = dict(raw)
|
||||
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
|
||||
if no:
|
||||
try:
|
||||
self._post(
|
||||
"/inquiry/agent/ticket/clearNextFollowup",
|
||||
{"workOrderNo": no, "work_order_no": no},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("强制清空再问时刻失败 wo=%s", no)
|
||||
claimed.append(row)
|
||||
return claimed
|
||||
|
||||
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
|
||||
"""主账按线路返回启用的海运产品岗。"""
|
||||
@@ -451,6 +471,43 @@ class HttpLedger:
|
||||
body["airline"] = airline.strip().upper()
|
||||
if (flight_no or "").strip():
|
||||
body["flightNo"] = flight_no.strip().upper()
|
||||
# #region agent log
|
||||
try:
|
||||
import json as _json
|
||||
import time as _time
|
||||
from pathlib import Path as _Path
|
||||
|
||||
_line = _json.dumps(
|
||||
{
|
||||
"sessionId": "e38e4c",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "H-E",
|
||||
"location": "http_ledger.py:lock_cabin",
|
||||
"message": "ledger_lock_called",
|
||||
"data": {
|
||||
"wo": work_order_no,
|
||||
"optionNo": option_no,
|
||||
"airline": (airline or "").strip(),
|
||||
"flightNo": (flight_no or "").strip(),
|
||||
"instruction_head": (instruction_text or "")[:80],
|
||||
},
|
||||
"timestamp": int(_time.time() * 1000),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
) + "\n"
|
||||
for _p in (
|
||||
_Path(r"d:\own\友通达询价智能体(新)\debug-e38e4c.log"),
|
||||
_Path(r"C:/Users/ytd_extdev_0903/incoming/debug-e38e4c.log"),
|
||||
):
|
||||
try:
|
||||
_p.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _p.open("a", encoding="utf-8") as _fh:
|
||||
_fh.write(_line)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# #endregion
|
||||
return self._post(
|
||||
"/inquiry/agent/tms/lockCabin",
|
||||
body,
|
||||
|
||||
@@ -109,7 +109,8 @@ class MemoryLedger:
|
||||
self._staff_by_wecom: dict[str, dict[str, Any]] = {}
|
||||
self._staff_by_name: dict[str, dict[str, Any]] = {}
|
||||
self._exception_notify_users: list[dict[str, Any]] = []
|
||||
self._timeouts: dict[str, float] = {}
|
||||
# 单测默认带协商再问 48 工作小时,与主账 deal_negotiate 种子一致
|
||||
self._timeouts: dict[str, float] = {"deal_negotiate": 48.0}
|
||||
|
||||
def _next_no(self) -> str:
|
||||
self._seq += 1
|
||||
|
||||
@@ -11,14 +11,17 @@ import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS
|
||||
try:
|
||||
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS
|
||||
except ImportError: # 测服旧 release 未导出同义词时仍可抽产品价
|
||||
QUOTE_DATE_SYNONYMS = ()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 报价日期同义词。长的在前,避免短词截断。货好时间、提货地址不在这里。
|
||||
_QUOTE_DATE_LABEL_RE = "|".join(
|
||||
re.escape(name)
|
||||
for name in sorted(("报价日期",) + QUOTE_DATE_SYNONYMS, key=len, reverse=True)
|
||||
for name in sorted(("报价日期",) + tuple(QUOTE_DATE_SYNONYMS or ()), key=len, reverse=True)
|
||||
)
|
||||
|
||||
_MODE_WORDS = (
|
||||
@@ -43,7 +46,7 @@ _LABEL_KEYS = (
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|"
|
||||
+ _QUOTE_DATE_LABEL_RE
|
||||
+ r"|运输分类|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸"
|
||||
)
|
||||
_LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$")
|
||||
@@ -65,6 +68,8 @@ _OPTIONAL_INQUIRY_LABELS = (
|
||||
"收货地址",
|
||||
"送货地址",
|
||||
"贸易条款",
|
||||
"增值服务(非必填)",
|
||||
"增值服务",
|
||||
"起运港",
|
||||
)
|
||||
# 图上常见「4. 希望送到…」:编号项不是询价字段,值收到这里为止。
|
||||
@@ -109,6 +114,7 @@ _CLARIFY_NAMES = frozenset(
|
||||
"送货地址",
|
||||
"通关口岸",
|
||||
"始发站",
|
||||
"增值服务",
|
||||
}
|
||||
)
|
||||
# 报价单表头和格子在同一行:只洗这些询价键,不碰提货/收货地址。
|
||||
@@ -474,6 +480,7 @@ def extract_inquiry_snapshot(
|
||||
if str(facts.get(key) or "").strip():
|
||||
facts[key] = clip_inquiry_value(str(facts[key]))
|
||||
facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts))
|
||||
facts = _apply_air_added_services(text, facts, mode)
|
||||
subtype = str(facts.get("运输类型") or "").strip()
|
||||
return {
|
||||
"business_line": mode,
|
||||
@@ -521,6 +528,7 @@ def extract_inquiry_snapshot(
|
||||
if str(facts.get(key) or "").strip():
|
||||
facts[key] = clip_inquiry_value(str(facts[key]))
|
||||
facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts))
|
||||
facts = _apply_air_added_services(text, facts, mode)
|
||||
land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip()
|
||||
if land_subtype and not str(facts.get("运输类型") or "").strip():
|
||||
facts["运输类型"] = land_subtype
|
||||
@@ -700,6 +708,15 @@ def _clip_sheet_inquiry_facts(facts: dict[str, str]) -> dict[str, str]:
|
||||
return {key: val for key, val in out.items() if str(val or "").strip()}
|
||||
|
||||
|
||||
def _apply_air_added_services(text: str, facts: dict[str, str], mode: str) -> dict[str, str]:
|
||||
"""空运才收增值服务。其他业务线原样返回,避免海运口播里的托盘被记进来。"""
|
||||
if (mode or "").strip().upper() != "AIR":
|
||||
return facts
|
||||
from agent.schema.added_services import apply_air_value_added
|
||||
|
||||
return apply_air_value_added(text, facts)
|
||||
|
||||
|
||||
def _merge_optional_inquiry_labels(text: str, facts: dict[str, str]) -> dict[str, str]:
|
||||
"""把原话里的通关口岸、货好时间、提货/收货地址写进询价事实。有值才覆盖。"""
|
||||
from agent.schema.field_validate import normalize_facts
|
||||
@@ -751,6 +768,9 @@ def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
|
||||
begin = pos + len(label)
|
||||
end = kept[i + 1][0] if i + 1 < len(kept) else len(raw)
|
||||
chunk = raw[begin:end]
|
||||
# 增值服务只认冒号后同一行。空行不能把下一行「报价日期(非必填):」收成服务。
|
||||
if label.startswith("增值服务"):
|
||||
chunk = chunk.split("\n", 1)[0]
|
||||
chunk = re.sub(r"^[\s::,,、]+", "", chunk)
|
||||
chunk = re.sub(r"[\s,,、]+$", "", chunk)
|
||||
if chunk:
|
||||
@@ -829,9 +849,11 @@ def extract_air_collab_fields(
|
||||
return {k: got[k] for k in keys if str(got.get(k) or "").strip()}
|
||||
|
||||
|
||||
# 金额旁币种词:含港币;禁止只认 USD/CNY 把港币单漏成美元。
|
||||
_CCY_TOKEN = r"USD|CNY|RMB|HKD|EUR|JPY|GBP|美金|美元|人民币|港币|港元|欧元|日元|英镑"
|
||||
_RE_MONEY = re.compile(
|
||||
r"(?:USD|CNY|RMB|美金|美元|人民币)?\s*(\d+(?:\.\d+)?)\s*(?:USD|CNY|RMB|美金|美元|人民币|元)?"
|
||||
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
|
||||
rf"(?:{_CCY_TOKEN}|元)?\s*(\d+(?:\.\d+)?)\s*(?:{_CCY_TOKEN}|元)?"
|
||||
rf"|(?:{_CCY_TOKEN})\s*(\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
# 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。
|
||||
@@ -839,14 +861,14 @@ _RE_WEIGHT_BAND_PRICE = re.compile(
|
||||
r"(?P<min>\d+)\s*[-~~—–到至]\s*(?P<max>\d+)\s*K\s*G"
|
||||
r"(?:\s*以上)?"
|
||||
r"\s*(?:单价)?"
|
||||
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
rf"\s*(?:(?P<ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<price>\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
_RE_WEIGHT_BAND_OPEN = re.compile(
|
||||
r"(?P<min>\d+)\s*K\s*G\s*以上"
|
||||
r"\s*(?:单价)?"
|
||||
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
rf"\s*(?:(?P<ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<price>\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
@@ -862,9 +884,9 @@ _RE_FEE_LINE = re.compile(
|
||||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
|
||||
r"\s*(?:改为|改成|调整为|调整成)?"
|
||||
r"\s*[::=]?"
|
||||
r"\s*(?:(?P<ccy1>USD|CNY|RMB|美金|美元|人民币)\s+)?"
|
||||
rf"\s*(?:(?P<ccy1>{_CCY_TOKEN})\s+)?"
|
||||
r"(?P<amount>\d+(?:\.\d+)?)"
|
||||
r"\s*(?P<ccy2>USD|CNY|RMB|美金|美元|人民币|元)?",
|
||||
rf"\s*(?P<ccy2>{_CCY_TOKEN}|元)?",
|
||||
re.I,
|
||||
)
|
||||
# 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。
|
||||
@@ -873,19 +895,24 @@ _RE_FEE_CHANGE = re.compile(
|
||||
r"\s*(?:改为|改成|调整为|调整成)"
|
||||
r"\s*[::=]?"
|
||||
r"\s*(?P<amount>\d+(?:\.\d+)?)"
|
||||
r"\s*(?P<ccy>USD|CNY|RMB|美金|美元|人民币|元)?",
|
||||
rf"\s*(?P<ccy>{_CCY_TOKEN}|元)?",
|
||||
re.I,
|
||||
)
|
||||
_SKIP_FEE_NAMES = frozenset({"usd", "cny", "rmb", "美金", "美元", "人民币", "元"})
|
||||
_SKIP_FEE_NAMES = frozenset(
|
||||
{"usd", "cny", "rmb", "hkd", "eur", "jpy", "gbp", "美金", "美元", "人民币", "港币", "港元", "元"}
|
||||
)
|
||||
# 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。
|
||||
# WO202610130003:协同句常见「空运费, KG, 单价CNY99.00, 数量100, 金额CNY9900」——费名与单价之间夹单位;
|
||||
# 数量/金额前也可能只有空格无逗号。漏匹配会落到 _RE_FEE_LINE,把单价 99 当成行金额、数量被补成 1。
|
||||
_RE_FEE_PRICED = re.compile(
|
||||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
|
||||
r"\s*单价\s*"
|
||||
r"(?:(?P<unit_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
r"(?:\s*[,,、]\s*(?:KG|KGS|公斤|千克|TON|CBM|柜|票|板|点|[A-Za-z]{1,8}))?"
|
||||
r"\s*[,,、]?\s*单价\s*"
|
||||
rf"(?:(?P<unit_ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<unit>\d+(?:\.\d+)?)"
|
||||
r"(?:\s*[,,、]\s*数量\s*(?P<qty>\d+(?:\.\d+)?))?"
|
||||
r"(?:\s*[,,、]\s*金额\s*"
|
||||
r"(?:(?P<amt_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
r"(?:\s*[,,、]?\s*数量\s*(?P<qty>\d+(?:\.\d+)?))?"
|
||||
r"(?:\s*[,,、]?\s*金额\s*"
|
||||
rf"(?:(?P<amt_ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<amount>\d+(?:\.\d+)?))?",
|
||||
re.I,
|
||||
)
|
||||
@@ -898,21 +925,40 @@ def _ascii_amount(raw: str) -> str:
|
||||
|
||||
|
||||
def _currency_of(text: str, hint: str = "") -> str:
|
||||
"""
|
||||
从正文/提示抽三字码。正文没写币种时只回传 hint,禁止默认编造 USD。
|
||||
|
||||
港币必须先于「元」判断,避免「港元」被误判成 CNY。
|
||||
WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时必须继承 HKD。
|
||||
"""
|
||||
blob = f"{hint} {text}".upper()
|
||||
if any(x in blob for x in ("CNY", "RMB", "人民币", "元")):
|
||||
raw = f"{hint} {text}"
|
||||
if any(x in blob for x in ("HKD", "港币", "港元", "HK$")) or "港币" in raw or "港元" in raw:
|
||||
return "HKD"
|
||||
if any(x in blob for x in ("CNY", "RMB", "人民币")) or (
|
||||
"元" in raw and "港元" not in raw and "港币" not in raw
|
||||
):
|
||||
return "CNY"
|
||||
if any(x in blob for x in ("USD", "美金", "美元")):
|
||||
return "USD"
|
||||
return hint or "USD"
|
||||
if any(x in blob for x in ("EUR", "欧元")):
|
||||
return "EUR"
|
||||
if any(x in blob for x in ("JPY", "日元")):
|
||||
return "JPY"
|
||||
if any(x in blob for x in ("GBP", "英镑")):
|
||||
return "GBP"
|
||||
return str(hint or "").strip().upper()
|
||||
|
||||
|
||||
def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]:
|
||||
"""
|
||||
按行抽费用:码头操作费100CNY、报关费改为300CNY。
|
||||
|
||||
只认带「费」的中文名或短英文费用码,避免把工单号当金额。
|
||||
句里无币种时继承 baseline_currency(通常是当前 TMS/主账币种),禁止默认 USD。
|
||||
"""
|
||||
raw = _ascii_amount(text or "")
|
||||
base_ccy = str(baseline_currency or "").strip().upper()
|
||||
rows: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
@@ -925,10 +971,11 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
if name in seen:
|
||||
return
|
||||
seen.add(name)
|
||||
line_ccy = _currency_of(raw, ccy or base_ccy) or base_ccy
|
||||
row = {
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"currency": _currency_of(raw, ccy),
|
||||
"currency": line_ccy,
|
||||
}
|
||||
for key, val in extra.items():
|
||||
if val:
|
||||
@@ -961,7 +1008,33 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
return rows
|
||||
|
||||
|
||||
def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
def _default_product_fee_name(business_line: str = "", transport_mode: str = "") -> str:
|
||||
"""
|
||||
产品文字未点费用名时的默认费目。
|
||||
|
||||
必须跟本单运输方式走:陆运→陆运费、空运→空运费、其余→海运费。
|
||||
禁止陆运单无费名时硬编码「海运费」(WO202610090118「价格为1RMB/KG」)。
|
||||
"""
|
||||
line = (business_line or "").strip().upper()
|
||||
mode = (transport_mode or "").strip()
|
||||
if line == "LAND" or mode in {"陆运", "LAND", "land"}:
|
||||
return "陆运费"
|
||||
if line == "AIR" or mode in {"空运", "AIR", "air"}:
|
||||
return "空运费"
|
||||
if line == "SEA" or mode in {"海运", "SEA", "sea"}:
|
||||
return "海运费"
|
||||
# 未传业务线时保持旧默认,避免海运调用方漏参后费名变空。
|
||||
return "海运费"
|
||||
|
||||
|
||||
def extract_product_quote(
|
||||
text: str,
|
||||
*,
|
||||
injected: Optional[dict[str, Any]] = None,
|
||||
business_line: str = "",
|
||||
transport_mode: str = "",
|
||||
baseline_currency: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
产品文字报价:先按费用行抽,再退回合计/币别。注入优先。
|
||||
|
||||
@@ -970,7 +1043,10 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
|
||||
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
|
||||
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
|
||||
无费用名时按 business_line/transport_mode 默认陆运费/空运费/海运费,禁止陆运硬套海运费。
|
||||
正文未写币种时继承 baseline_currency(当前 TMS/主账币种),禁止默认 USD(WO107)。
|
||||
"""
|
||||
base_ccy = str(baseline_currency or "").strip().upper()
|
||||
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
||||
out = dict(injected)
|
||||
out.setdefault("source_label", "产品报价")
|
||||
@@ -982,14 +1058,14 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
compact = stripped.replace(" ", "").replace(" ", "")
|
||||
if re.fullmatch(r"WO\d{12}", compact, re.I):
|
||||
return {}
|
||||
band = _parse_weight_band_quote(raw)
|
||||
band = _parse_weight_band_quote(raw, baseline_currency=base_ccy)
|
||||
if band:
|
||||
return band
|
||||
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
|
||||
scrubbed = _strip_rate_and_band_noise(raw)
|
||||
rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else []
|
||||
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
|
||||
if rows:
|
||||
ccy = rows[0].get("currency") or _currency_of(raw)
|
||||
ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy
|
||||
money_rows = [x for x in rows if "%" not in str(x.get("amount") or "")]
|
||||
total = (money_rows[0] if money_rows else rows[0])["amount"]
|
||||
if money_rows and len({x.get("currency") for x in money_rows}) == 1:
|
||||
@@ -1014,16 +1090,20 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
|
||||
if not amount:
|
||||
return {}
|
||||
ccy = _currency_of(raw)
|
||||
ccy = _currency_of(raw, base_ccy) or base_ccy
|
||||
default_fee = _default_product_fee_name(business_line, transport_mode)
|
||||
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
|
||||
named = re.search(
|
||||
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
|
||||
scrubbed or raw,
|
||||
)
|
||||
fee_name = (named.group(1) if named else "") or "海运费"
|
||||
if fee_name in {"费用", "燃油附加费"}:
|
||||
# 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。
|
||||
fee_name = "海运费" if fee_name == "费用" else "陆运费"
|
||||
fee_name = (named.group(1) if named else "") or default_fee
|
||||
if fee_name == "费用":
|
||||
# 句里只有「费用」没有运输方式前缀时,跟本单默认运费。
|
||||
fee_name = default_fee
|
||||
elif fee_name == "燃油附加费":
|
||||
# 「燃油附加费率」误抽成费名时:陆运/空运用本单默认;海运旧行为落陆运费,避免当海运费金额。
|
||||
fee_name = default_fee if default_fee != "海运费" else "陆运费"
|
||||
out = {
|
||||
"total": amount,
|
||||
"currency": ccy,
|
||||
@@ -1036,6 +1116,7 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
return out
|
||||
|
||||
|
||||
|
||||
def _format_percent_rate(raw: str) -> str:
|
||||
"""费率展示统一带 %;已有 % 保留。"""
|
||||
text = (raw or "").strip()
|
||||
@@ -1070,13 +1151,15 @@ def _strip_rate_and_band_noise(text: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
def _parse_weight_band_quote(text: str, baseline_currency: str = "") -> dict[str, Any]:
|
||||
"""
|
||||
陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。
|
||||
|
||||
产出 weightPrices,供主账出单按「2-500KG」表头对档;费率进顶层字段填 Excel。
|
||||
无币种时优先继承 baseline,再退回 CNY(集拼常见人民币报价)。
|
||||
"""
|
||||
raw = _ascii_amount(text or "")
|
||||
base_ccy = str(baseline_currency or "").strip().upper()
|
||||
tiers: list[dict[str, Any]] = []
|
||||
rows: list[dict[str, str]] = []
|
||||
ccy = ""
|
||||
@@ -1086,7 +1169,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
price = _ascii_amount(hit.group("price") or "")
|
||||
if not price:
|
||||
continue
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or "")
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or base_ccy) or base_ccy
|
||||
ccy = ccy or line_ccy
|
||||
label = f"{lo}-{hi}KG"
|
||||
tiers.append({"min": lo, "max": hi, "price": price})
|
||||
@@ -1103,7 +1186,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
price = _ascii_amount(hit.group("price") or "")
|
||||
if not price:
|
||||
continue
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or "")
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or base_ccy) or base_ccy
|
||||
ccy = ccy or line_ccy
|
||||
label = f"{lo}KG以上"
|
||||
tiers.append({"min": lo, "max": None, "price": price})
|
||||
@@ -1132,7 +1215,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
# 分档是单价,不能把多档相加冒充费用合计。
|
||||
out: dict[str, Any] = {
|
||||
"total": tiers[0]["price"],
|
||||
"currency": ccy or "CNY",
|
||||
"currency": ccy or base_ccy or "CNY",
|
||||
"source_label": "产品报价",
|
||||
"raw_text": (text or "").strip(),
|
||||
"fee_rows": rows,
|
||||
|
||||
@@ -48,6 +48,8 @@ _PENDING_LOCK = "__pending_air_lock"
|
||||
_PENDING_LOCK_OPTION = "__pending_lock_option"
|
||||
_PENDING_LOCK_AIRLINE = "__pending_lock_airline"
|
||||
_PENDING_LOCK_FLIGHT = "__pending_lock_flight"
|
||||
|
||||
|
||||
_LOCK_ID = "__air_lock_id"
|
||||
_LOCK_OPTION = "__air_lock_option"
|
||||
_LOCK_QUOTE = "__air_lock_quote"
|
||||
@@ -629,12 +631,19 @@ def apply_air_quote(
|
||||
fields = extract_air_collab_fields(text)
|
||||
if fields:
|
||||
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
quote = injected_quote or extract_product_quote(text) or {}
|
||||
view = ticket_view(ticket)
|
||||
current = dict(view.get("quote") or {})
|
||||
from agent.policy.sea_group_ops import _quote_currency_hint
|
||||
|
||||
quote = injected_quote or extract_product_quote(
|
||||
text,
|
||||
business_line="AIR",
|
||||
transport_mode="空运",
|
||||
baseline_currency=_quote_currency_hint(current),
|
||||
) or {}
|
||||
if not quote:
|
||||
send_group_text(client, chat_id, copy.air_quote_not_recognized())
|
||||
return "quote_empty"
|
||||
view = ticket_view(ticket)
|
||||
current = dict(view.get("quote") or {})
|
||||
if _has_tms_quote(current) and not _is_product_quote(current):
|
||||
_park_air_pending(flow, no, quote, sender_id)
|
||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||
@@ -690,6 +699,9 @@ def apply_air_file(
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(flow.ledger, work_order_no=no)
|
||||
# 附件不拆金额:锁舱常失败。产品口径改为提示航线改发纯文字报价后再锁。
|
||||
tip = copy.air_attachment_quote_lock_tip(work_order_no=no)
|
||||
send_group_text(client, chat_id, tip)
|
||||
emit_group_deal(flow=flow, ticket=view, quote=quote)
|
||||
return "airline_file_quote"
|
||||
|
||||
@@ -854,6 +866,7 @@ def apply_air_deal(
|
||||
no,
|
||||
str(result.get("next_followup_at") or ""),
|
||||
lost_reason=ack_reason,
|
||||
followup_hours=result.get("negotiate_hours"),
|
||||
),
|
||||
)
|
||||
return phase
|
||||
@@ -942,7 +955,8 @@ def apply_air_lock(
|
||||
return "no_quote"
|
||||
# 主账在出站前发现三项不齐:只请补,不当 TMS 故障,不通知 IT。
|
||||
reason_text = str(out.get("failReason") or "")
|
||||
if str(out.get("error") or "") == "lock_params_incomplete" or "锁舱参数不完整" in reason_text:
|
||||
err_code = str(out.get("error") or "")
|
||||
if err_code == "lock_params_incomplete" or "锁舱参数不完整" in reason_text:
|
||||
labels = missing or _labels_from_incomplete_reason(reason_text)
|
||||
_remember_partial_lock(
|
||||
flow,
|
||||
@@ -954,8 +968,45 @@ def apply_air_lock(
|
||||
)
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(labels))
|
||||
return "wait_lock_params"
|
||||
# 附件/手工报价、运价库无舱位:引导纯文字报价,不算系统异常、不通知 IT。
|
||||
src_label = str((quote or {}).get("source_label") or (quote or {}).get("source") or "")
|
||||
need_text_quote = (
|
||||
err_code in {"lock_incomplete", "lock_fail"}
|
||||
and (
|
||||
"航线附件报价" in src_label
|
||||
or "附件" in src_label
|
||||
or any(
|
||||
x in reason_text
|
||||
for x in (
|
||||
"手工报价",
|
||||
"无匹配舱位",
|
||||
"无匹配报价",
|
||||
"运价库无匹配",
|
||||
"标准运价库无匹配",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if need_text_quote:
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_lock_need_text_quote(
|
||||
work_order_no=no, operator=name, reason=reason_text
|
||||
),
|
||||
)
|
||||
return "need_text_quote"
|
||||
if err_code == "lock_incomplete":
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_lock_incomplete(
|
||||
work_order_no=no, operator=name, reason=reason_text
|
||||
),
|
||||
)
|
||||
return "lock_incomplete"
|
||||
# 多段是产品限制,不是 TMS 故障。只回原因,不标系统异常、不暂停工单。
|
||||
if str(out.get("error") or "") == "multi_no_lock":
|
||||
if err_code == "multi_no_lock":
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
@@ -1511,11 +1562,13 @@ def _resolve_lock_params(
|
||||
air = _normalize_airline(airline or said_air or str(facts.get(_PENDING_LOCK_AIRLINE) or ""))
|
||||
flight = _normalize_flight(flight_no or said_flight or str(facts.get(_PENDING_LOCK_FLIGHT) or ""))
|
||||
chosen = _option_row(quote, option)
|
||||
single_auto = False
|
||||
if chosen is None and not option:
|
||||
options = _air_options(quote)
|
||||
if len(options) == 1:
|
||||
chosen = options[0]
|
||||
option = str(chosen.get("optionNo") or chosen.get("option_no") or "").strip()
|
||||
single_auto = True
|
||||
if not _airline_ready(air) and chosen is not None:
|
||||
air = _normalize_airline(
|
||||
str(chosen.get("airlineCode") or chosen.get("airline") or "")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""
|
||||
空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。
|
||||
|
||||
本文件职责:补问到必填齐 → 核对确定后建单 → 查 TMS(一条出卡,多条 H5 选线路)。
|
||||
本文件职责:补问到必填齐 → 核对确定后建单 → 查 TMS。
|
||||
说了报价日期:一条直接出卡,没写起运地且多条才进选线路。
|
||||
没说报价日期:不填当天,查价不带日期,返回的舱位全部进 H5(1 条也要点),选定后不再查价。
|
||||
对不上机场也不能跳过查价、本地说暂无报价。
|
||||
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
|
||||
禁止:直连 TMS;用图状态冒充主账成功;用户文字冒充文件已生成。
|
||||
@@ -971,6 +973,8 @@ class AirTextInquiryFlow:
|
||||
# 让本句按新询价走,避免正文里的「确认」误伤。
|
||||
sess = self._detach_ticketed_for_new_inquiry(sess)
|
||||
|
||||
added_source = text
|
||||
prev_added = ""
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
injected_facts=injected_facts,
|
||||
@@ -994,19 +998,26 @@ class AirTextInquiryFlow:
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
|
||||
# 补问轮:旧字段打底;运输方式沿用上一句(飞=空运)
|
||||
# 增值服务按句累加,本句没提时不能把上一句已记下的服务冲掉。
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.schema.field_validate import harvest_oral_measures
|
||||
|
||||
prev_added = str((sess.facts or {}).get("added_services") or "")
|
||||
merged = dict(sess.facts)
|
||||
merged.update(facts)
|
||||
source = waiting_mode_source(sess, text)
|
||||
facts = harvest_oral_measures(source, merged)
|
||||
added_source = waiting_mode_source(sess, text)
|
||||
facts = harvest_oral_measures(added_source, merged)
|
||||
mode = mode or (sess.business_line or "").upper()
|
||||
if not mode:
|
||||
mode = detect_transport_mode(
|
||||
f"{sess.immutable_text or ''}\n{text}"
|
||||
)
|
||||
|
||||
if (mode or "").upper() == "AIR":
|
||||
from agent.schema.added_services import apply_air_value_added
|
||||
|
||||
facts = apply_air_value_added(added_source, facts, previous=prev_added)
|
||||
|
||||
if not mode:
|
||||
reply(copy.ask_transport())
|
||||
self._save(
|
||||
@@ -1268,9 +1279,31 @@ class AirTextInquiryFlow:
|
||||
options_matching_origin,
|
||||
parse_air_options,
|
||||
project_selected_quote,
|
||||
sort_cabin_options,
|
||||
)
|
||||
|
||||
options = parse_air_options(quote)
|
||||
# 没填报价日期:不按起运地收口,TMS 返回的舱位全部给销售点。1 条也要点。
|
||||
if not str((sess.facts or {}).get("报价日期") or "").strip() and options:
|
||||
quote = dict(quote)
|
||||
quote["airOptions"] = sort_cabin_options(options)
|
||||
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
|
||||
if not up.get("ok"):
|
||||
reply("主账写入报价失败,不能当作已报价。")
|
||||
sess.phase = "ledger_fail"
|
||||
self._save(sess)
|
||||
return "ledger_fail"
|
||||
sess.quote = quote
|
||||
copy.remember_tms_quote(sess, quote)
|
||||
sess.tms_quote = dict(quote)
|
||||
sess.status = "已报价"
|
||||
return self._offer_air_routes(
|
||||
sess=sess,
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
reply=reply,
|
||||
cabin_pick=True,
|
||||
)
|
||||
stated_origin = str(
|
||||
(sess.facts or {}).get("起运港") or (sess.facts or {}).get("起运地") or ""
|
||||
).strip()
|
||||
@@ -1335,8 +1368,13 @@ class AirTextInquiryFlow:
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
reply: ReplyFn,
|
||||
cabin_pick: bool = False,
|
||||
) -> str:
|
||||
"""多条线路:企微只发入口卡,选线在 H5。"""
|
||||
"""
|
||||
企微只发入口卡,点选在 H5。
|
||||
|
||||
cabin_pick:没填报价日期,列出全部舱位(含 1 条)。选定后用该条总价出报价,不再查价。
|
||||
"""
|
||||
from agent.channel.h5.air_route_store import build_air_route_url, issue_air_route_token
|
||||
from agent.schema.air_route_options import parse_air_options
|
||||
|
||||
@@ -1345,6 +1383,7 @@ class AirTextInquiryFlow:
|
||||
work_order_no=work_order_no,
|
||||
quote=quote,
|
||||
facts=dict(sess.facts),
|
||||
cabin_pick=cabin_pick,
|
||||
)
|
||||
url = build_air_route_url(token)
|
||||
count = len(parse_air_options(quote))
|
||||
@@ -1353,8 +1392,12 @@ class AirTextInquiryFlow:
|
||||
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),
|
||||
copy.air_route_entry_text(
|
||||
work_order_no=work_order_no, count=count, cabin_pick=cabin_pick
|
||||
),
|
||||
copy.air_route_entry_card(
|
||||
work_order_no=work_order_no, count=count, url=url, cabin_pick=cabin_pick
|
||||
),
|
||||
)
|
||||
return "wait_air_route"
|
||||
|
||||
@@ -2007,6 +2050,7 @@ class AirTextInquiryFlow:
|
||||
sess.work_order_no,
|
||||
str(result.get("next_followup_at") or ""),
|
||||
lost_reason=ack_reason,
|
||||
followup_hours=result.get("negotiate_hours"),
|
||||
)
|
||||
)
|
||||
return phase
|
||||
|
||||
@@ -88,7 +88,7 @@ def shift_workdays(start: date, count: int) -> date:
|
||||
"""
|
||||
从 start 当天之后数 count 个工作日。
|
||||
|
||||
成交选协商中:顺延 3 个工作日后,第四个工作日再问,故 count=4。
|
||||
仍给单测与节假日校验用;协商再问已改走 add_workday_hours。
|
||||
"""
|
||||
if count < 1:
|
||||
return start
|
||||
@@ -101,11 +101,51 @@ def shift_workdays(start: date, count: int) -> date:
|
||||
return cur
|
||||
|
||||
|
||||
def next_followup_at(now: datetime | None = None) -> datetime:
|
||||
"""第四个工作日 Asia/Shanghai 09:00。"""
|
||||
# 协商中再问:主账 timeoutCode;缺配置时默认 48 工作小时。
|
||||
TIMEOUT_CODE_DEAL_NEGOTIATE = "deal_negotiate"
|
||||
DEFAULT_NEGOTIATE_HOURS = 48.0
|
||||
|
||||
|
||||
def add_workday_hours(start: datetime, hours: float) -> datetime:
|
||||
"""
|
||||
从 start 起累加 hours 个工作小时;非工作日整天跳过。
|
||||
|
||||
与 workday_elapsed_hours 互逆:工作日当天 0–24 都算。
|
||||
起点落在休息日:从下一个工作日 00:00 起算。
|
||||
hours≤0 时原样返回(调用方应不排期)。
|
||||
"""
|
||||
base = start.astimezone(SHANGHAI) if start.tzinfo else start.replace(tzinfo=SHANGHAI)
|
||||
remaining = float(hours)
|
||||
if remaining <= 0:
|
||||
return base
|
||||
cur = base
|
||||
if not is_workday(cur.date()):
|
||||
cur = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
|
||||
while not is_workday(cur.date()):
|
||||
cur = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
|
||||
while remaining > 0:
|
||||
if not is_workday(cur.date()):
|
||||
cur = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
|
||||
continue
|
||||
day_end = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
|
||||
room = (day_end - cur).total_seconds() / 3600.0
|
||||
if remaining <= room + 1e-9:
|
||||
return cur + timedelta(hours=remaining)
|
||||
remaining -= room
|
||||
cur = day_end
|
||||
return cur
|
||||
|
||||
|
||||
def next_followup_at(now: datetime | None = None, *, hours: float | None = None) -> datetime:
|
||||
"""
|
||||
距今 hours 个工作小时后的再问时刻(默认 48)。
|
||||
|
||||
非工作日不计入;提醒时刻不再固定 09:00。
|
||||
hours≤0 时返回起点(调用方应把 0 当作不排再问)。
|
||||
"""
|
||||
base = now.astimezone(SHANGHAI) if now else datetime.now(SHANGHAI)
|
||||
day = shift_workdays(base.date(), 4)
|
||||
return datetime.combine(day, time(9, 0), tzinfo=SHANGHAI)
|
||||
h = DEFAULT_NEGOTIATE_HOURS if hours is None else float(hours)
|
||||
return add_workday_hours(base, h)
|
||||
|
||||
|
||||
def workday_elapsed_hours(start: datetime, now: datetime) -> float:
|
||||
|
||||
@@ -13,7 +13,12 @@ from typing import Any, Callable, Optional
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.deal_followup import next_followup_at, pick_leaders
|
||||
from agent.policy.deal_followup import (
|
||||
DEFAULT_NEGOTIATE_HOURS,
|
||||
TIMEOUT_CODE_DEAL_NEGOTIATE,
|
||||
next_followup_at,
|
||||
pick_leaders,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -156,8 +161,14 @@ def finish_deal_outcome(
|
||||
status = (outcome or "").strip()
|
||||
extra: dict[str, Any] = {}
|
||||
summary = ""
|
||||
negotiate_hours = 0.0
|
||||
if status == "协商中":
|
||||
extra["next_followup_at"] = next_followup_at().strftime("%Y-%m-%d %H:%M:%S")
|
||||
# 小时读后台超时响应 deal_negotiate;0=不排再问;缺配置默认 48
|
||||
negotiate_hours = _negotiate_followup_hours(ledger)
|
||||
if negotiate_hours > 0:
|
||||
extra["next_followup_at"] = next_followup_at(hours=negotiate_hours).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
if status == "已成交":
|
||||
staff: list[dict[str, Any]] = []
|
||||
lister = getattr(ledger, "list_staff_by_role", None)
|
||||
@@ -242,9 +253,38 @@ def finish_deal_outcome(
|
||||
"notify_summary": summary,
|
||||
"status": status,
|
||||
"next_followup_at": str(extra.get("next_followup_at") or ""),
|
||||
"negotiate_hours": negotiate_hours,
|
||||
}
|
||||
|
||||
|
||||
def _negotiate_followup_hours(ledger: Any) -> float:
|
||||
"""
|
||||
读主账 deal_negotiate 超时小时;找不到或异常则用默认 48。
|
||||
|
||||
允许小数;负数当 0(不排再问)。不在回调线程打主账以外的慢活。
|
||||
"""
|
||||
lookup = getattr(ledger, "get_timeout_by_code", None)
|
||||
if not callable(lookup):
|
||||
return DEFAULT_NEGOTIATE_HOURS
|
||||
try:
|
||||
row = lookup(timeout_code=TIMEOUT_CODE_DEAL_NEGOTIATE) or {}
|
||||
except Exception:
|
||||
logger.exception("读协商再问小时失败,用默认 %s", DEFAULT_NEGOTIATE_HOURS)
|
||||
return DEFAULT_NEGOTIATE_HOURS
|
||||
if not row.get("found"):
|
||||
return DEFAULT_NEGOTIATE_HOURS
|
||||
raw = row.get("timeoutHours")
|
||||
if raw is None:
|
||||
raw = row.get("timeout_hours")
|
||||
try:
|
||||
hours = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_NEGOTIATE_HOURS
|
||||
if hours < 0:
|
||||
return 0.0
|
||||
return hours
|
||||
|
||||
|
||||
def _transition_deal_status(
|
||||
writer: Callable[..., dict[str, Any]],
|
||||
*,
|
||||
|
||||
@@ -57,7 +57,7 @@ def _cabin_copy(code: str, default: str, **kwargs: Any) -> str:
|
||||
return text
|
||||
|
||||
|
||||
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
|
||||
# 空运补问:必填项 + 非必填。报价日期非必填,没说不默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
"起运港": "南京",
|
||||
"目的港": "吉隆坡",
|
||||
@@ -72,6 +72,7 @@ _AIR_EXAMPLES = {
|
||||
"提货地址": "东莞仓库",
|
||||
"收货地址": "马尼拉",
|
||||
"贸易条款": "DDP、DAP、CIF、CIP",
|
||||
"added_services": "托盘、前置仓、带电、卡转、卡转低消、MUST GO、备装、分泡、温控、快件、DG、TC",
|
||||
}
|
||||
_AIR_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
|
||||
@@ -567,15 +568,17 @@ def air_confirm_card(facts: dict[str, str] | None) -> str:
|
||||
"""
|
||||
确定查价前的核对卡。没有工单号。
|
||||
|
||||
起运地(非必填)紧挨在目的地(必填)上面。其余非必填仍在报价日期后面。
|
||||
起运地(非必填)紧挨在目的地(必填)上面。
|
||||
报价日期说了写成「报价日期:日期」;没说写成「报价日期(非必填):」,不填当天。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
lines = ["运输方式(必填):空运"]
|
||||
lines.append(_air_field_line("起运港", _air_known_value(src, "起运港"), required=False))
|
||||
for key, _label in AIR_REQUIRED_FIELDS:
|
||||
if key == "报价日期":
|
||||
val = _air_known_value(src, key) or (src.get("报价日期") or "").strip()
|
||||
lines.append(_air_field_line(key, val, required=True))
|
||||
val = _air_known_value(src, key)
|
||||
# 说了日期就只写日期;没说标明非必填并留空,禁止写成当天。
|
||||
lines.append(f"报价日期:{val}" if val else "报价日期(非必填):")
|
||||
continue
|
||||
lines.append(_air_field_line(key, _air_known_value(src, key), required=True))
|
||||
for key, _label in AIR_OPTIONAL_FIELDS:
|
||||
@@ -616,7 +619,8 @@ def ask_clarify(
|
||||
"""
|
||||
缺项补问:先回显已识别,再给可复制清单;示例另起一行,如「1.件数(必填):」下一行「(如:10)」。
|
||||
|
||||
报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
|
||||
空运报价日期:说了就写成「报价日期:日期」,没说写成「报价日期(非必填):」,不进待补充。
|
||||
海运报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
|
||||
空运非必填一并列出,没填进待补充,不挡查价。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
@@ -635,6 +639,10 @@ def ask_clarify(
|
||||
lines.append(f"起运地:{origin}")
|
||||
for key, label, _example in rows:
|
||||
val = known(src, key)
|
||||
if not sea and key == "报价日期":
|
||||
# 空运日期不默认当天。没说也要占一行,方便销售看到这项可以空着。
|
||||
lines.append(f"报价日期:{val}" if val else "报价日期(非必填):")
|
||||
continue
|
||||
if val:
|
||||
lines.append(f"{label}:{val}")
|
||||
if not sea:
|
||||
@@ -758,7 +766,9 @@ def inquiry_field_lines(
|
||||
dest = (facts.get("目的港") or facts.get("目的地") or "-").strip()
|
||||
cargo = (facts.get("品名") or "-").strip() or "-"
|
||||
pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-"
|
||||
quote_date = (facts.get("报价日期") or "-").strip() or "-"
|
||||
quote_date = (facts.get("报价日期") or "").strip()
|
||||
if quote_date in {"-", "—", "-"}:
|
||||
quote_date = ""
|
||||
if (transport_mode or "").strip() == "海运":
|
||||
qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-"
|
||||
load = (facts.get("整柜或拼柜") or "-").strip() or "-"
|
||||
@@ -776,7 +786,7 @@ def inquiry_field_lines(
|
||||
f"箱型箱量:{box}",
|
||||
f"贸易条款:{trade}",
|
||||
f"运输分类:{klass}",
|
||||
f"报价日期:{quote_date}",
|
||||
f"报价日期:{quote_date or '-'}",
|
||||
]
|
||||
if (transport_mode or "").strip() == "陆运":
|
||||
land_type = (facts.get("运输类型") or "").strip()
|
||||
@@ -794,7 +804,7 @@ def inquiry_field_lines(
|
||||
if land_type in _LAND_CUSTOMS_TYPES:
|
||||
lines.append(f"通关口岸:{(facts.get('通关口岸') or '').strip()}")
|
||||
return lines
|
||||
return [
|
||||
lines = [
|
||||
f"工单号:{work_order_no}",
|
||||
f"运输方式:{transport_mode}",
|
||||
f"起运地:{origin}",
|
||||
@@ -804,8 +814,13 @@ def inquiry_field_lines(
|
||||
f"重量(KG):{_with_unit(str(facts.get('毛重') or ''), 'KGS')}",
|
||||
f"体积(CBM):{_with_unit(str(facts.get('体积') or ''), 'CBM')}",
|
||||
f"包装类型:{pack}",
|
||||
f"报价日期:{quote_date}",
|
||||
f"报价日期:{quote_date}" if quote_date else "报价日期(非必填):",
|
||||
]
|
||||
# 建单确认和空运群:有内容才多一行。没填不出现。核对卡的空行不在这里。
|
||||
added = (facts.get("added_services") or facts.get("增值服务") or "").strip()
|
||||
if added and added not in {"-", "—", "-"}:
|
||||
lines.insert(-1, f"增值服务:{added}")
|
||||
return lines
|
||||
|
||||
|
||||
def inquiry_card(
|
||||
@@ -1379,30 +1394,41 @@ def tms_hit_card(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def air_route_entry_card(*, work_order_no: str, count: int, url: str) -> dict[str, Any]:
|
||||
def air_route_entry_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
count: int,
|
||||
url: str,
|
||||
cabin_pick: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
多条线路入口卡:短文案跳转 H5,避免企微截断。
|
||||
入口卡:短文案跳转 H5,避免企微截断。
|
||||
|
||||
cabin_pick 为真时按舱位条数原文展示(1 条也发)。
|
||||
说了报价日期的选线路仍至少写 2 条,和原来多线路入口一致。
|
||||
"""
|
||||
n = max(int(count or 0), 2)
|
||||
n = max(int(count or 0), 1 if cabin_pick else 2)
|
||||
noun = "舱位" if cabin_pick else "线路"
|
||||
fallback = "https://ai.ytd-scm.com/inquiry-form"
|
||||
action_url = (url or "").strip() or fallback
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"template_card": {
|
||||
"card_type": "text_notice",
|
||||
"source": {"desc": "TMS线路报价", "desc_color": 0},
|
||||
"main_title": {"title": f"{work_order_no}已查到{n}条线路"},
|
||||
"sub_title_text": "点击查看并选择一条线路,选定后回企微看价格明细。",
|
||||
"source": {"desc": "TMS舱位报价" if cabin_pick else "TMS线路报价", "desc_color": 0},
|
||||
"main_title": {"title": f"{work_order_no}已查到{n}条{noun}"},
|
||||
"sub_title_text": f"点击查看并选择一条{noun},选定后回企微看价格明细。",
|
||||
"card_action": {"type": 1, "url": action_url},
|
||||
"jump_list": [{"type": 1, "title": "查看并选择线路", "url": action_url}],
|
||||
"jump_list": [{"type": 1, "title": f"查看并选择{noun}", "url": action_url}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def air_route_entry_text(*, work_order_no: str, count: int) -> str:
|
||||
"""入口卡失败时的文字兜底。"""
|
||||
n = max(int(count or 0), 2)
|
||||
return f"{work_order_no}已查到{n}条线路,请点击卡片查看并选择。"
|
||||
def air_route_entry_text(*, work_order_no: str, count: int, cabin_pick: bool = False) -> str:
|
||||
"""入口卡失败时的文字兜底。没填日期时 1 条舱位也提示去点选。"""
|
||||
n = max(int(count or 0), 1 if cabin_pick else 2)
|
||||
noun = "舱位" if cabin_pick else "线路"
|
||||
return f"{work_order_no}已查到{n}条{noun},请点击卡片查看并选择。"
|
||||
|
||||
|
||||
def tms_hit_wecom_payload(
|
||||
@@ -2624,6 +2650,20 @@ def group_tms_confirm_hint(*, business_line: str = "") -> str:
|
||||
return f"请{who}回复“确认报价”确定采用TMS报价。\n{_GROUP_QUOTE_TAIL}"
|
||||
|
||||
|
||||
def multi_group_field_hint(*, includes_air: bool) -> str:
|
||||
"""
|
||||
多段群里每次带出各段询价字段时,贴在最后一行字段下面。
|
||||
|
||||
任一段是空运,喊航线或产品;没有空运只喊产品。成交跟进不走这里。
|
||||
注里说的是附件报价单只存档,不改报价写入规则。
|
||||
"""
|
||||
who = "航线或产品人员" if includes_air else "产品人员"
|
||||
return (
|
||||
f"{who}可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。\n"
|
||||
"(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"
|
||||
)
|
||||
|
||||
|
||||
def group_no_tms_quote_hint(*, business_line: str = "") -> str:
|
||||
"""
|
||||
TMS 无价时写在「暂无匹配」下方:谁可以用文字或 Excel 另报。
|
||||
@@ -2761,6 +2801,7 @@ def deal_ack(
|
||||
work_order_no: str,
|
||||
next_followup_at: str = "",
|
||||
lost_reason: str = "",
|
||||
followup_hours: float | None = None,
|
||||
) -> str:
|
||||
"""成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。"""
|
||||
no = (work_order_no or "").strip()
|
||||
@@ -2775,26 +2816,42 @@ def deal_ack(
|
||||
if reason:
|
||||
return f"已记录为「未成交」。{lost_reason_ack(reason, work_order_no=no)}"
|
||||
return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no)
|
||||
when = _followup_month_day(next_followup_at) or "第四个工作日"
|
||||
from agent.policy.event_exception import format_timeout_hours
|
||||
|
||||
# 小时缺省按后台默认 48;0 表示运营关掉再问,话术仍说明已记录协商中
|
||||
hours_val = 48.0 if followup_hours is None else float(followup_hours)
|
||||
hours_text = format_timeout_hours(hours_val if hours_val > 0 else 48.0)
|
||||
when = _followup_when_text(next_followup_at) or f"{hours_text} 个工作小时后"
|
||||
return _r(
|
||||
"negotiating.recorded",
|
||||
f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。",
|
||||
f"已记录为「协商中」。已顺延 {hours_text} 个工作小时,将于 {when} 再次发起成交状态询问。",
|
||||
followup_when=when,
|
||||
followup_hours=hours_text,
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
def _followup_month_day(raw: str) -> str:
|
||||
"""把主账再问时刻收成「9月24日」。"""
|
||||
"""把主账再问时刻收成「9月24日」(旧模板兼容)。"""
|
||||
when = _followup_when_text(raw)
|
||||
if "日" in when:
|
||||
return when.split("日", 1)[0] + "日"
|
||||
return when
|
||||
|
||||
|
||||
def _followup_when_text(raw: str) -> str:
|
||||
"""把主账再问时刻收成「9月24日15:30」。"""
|
||||
from datetime import datetime
|
||||
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
|
||||
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
|
||||
try:
|
||||
dt = datetime.strptime(text[:n], fmt)
|
||||
return f"{dt.month}月{dt.day}日"
|
||||
if n <= 10:
|
||||
return f"{dt.month}月{dt.day}日"
|
||||
return f"{dt.month}月{dt.day}日{dt.hour}:{dt.minute:02d}"
|
||||
except ValueError:
|
||||
continue
|
||||
return ""
|
||||
@@ -3242,13 +3299,72 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def air_text_quote_for_lock_example(work_order_no: str = "") -> str:
|
||||
"""
|
||||
需要锁舱时,请航线用纯文字报价的示例句。
|
||||
|
||||
附件报价不拆金额、常锁不成;产品口径改为引导航线改发文字价。
|
||||
"""
|
||||
no = (work_order_no or "").strip() or "工单号"
|
||||
return f"{no},空运段,空运费 单价CNY99.00,数量100 ,金额CNY9900.00@询价小助手"
|
||||
|
||||
|
||||
def air_attachment_quote_lock_tip(*, work_order_no: str) -> str:
|
||||
"""
|
||||
航线刚发完 Excel/PDF 附件报价后的提示。
|
||||
|
||||
附件只存档、不拆费用;若还要锁舱,必须再发一版纯文字报价。
|
||||
"""
|
||||
no = (work_order_no or "").strip() or "WO"
|
||||
example = air_text_quote_for_lock_example(no)
|
||||
return (
|
||||
f"工单{no}附件报价已收。\n"
|
||||
"如需锁舱,请航线人员改用纯文字报价(不要只发附件),例如:\n"
|
||||
f"{example}"
|
||||
)
|
||||
|
||||
|
||||
def air_lock_need_text_quote(*, work_order_no: str, operator: str = "", reason: str = "") -> str:
|
||||
"""
|
||||
附件/手工报价锁舱走不通时:引导航线先发文字报价,不算系统异常、不通知 IT。
|
||||
|
||||
故意不回主账/TMS 原文「原因」(如 AIR-OPT / 运价库无匹配),只留文字报价指引。
|
||||
reason 参数保留兼容旧调用,出站话术不用。
|
||||
"""
|
||||
_ = reason
|
||||
no = (work_order_no or "").strip() or "WO"
|
||||
who = (operator or "").strip()
|
||||
lines = [f"工单{no} 还不能锁舱。"]
|
||||
if who:
|
||||
lines.append(f"操作人:{who}")
|
||||
lines.append("如需锁舱,请航线人员先发纯文字报价,例如:")
|
||||
lines.append(air_text_quote_for_lock_example(no))
|
||||
lines.append("文字报价写入后再发锁舱口令(可带舱位、航司、航班号)。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def air_lock_incomplete(*, work_order_no: str, operator: str, reason: str = "") -> str:
|
||||
"""
|
||||
锁舱参数不齐(纯手工价、运价库无舱位等):业务补问,不算系统异常,不惊动 IT。
|
||||
|
||||
航线按示例回舱位+航司+航班后即可再锁。
|
||||
附件/无舱位场景改引导纯文字报价;有 TMS 舱位时仍可补 AIR-OPT 三项。
|
||||
"""
|
||||
why = (reason or "").strip()
|
||||
blob = why
|
||||
if any(
|
||||
x in blob
|
||||
for x in (
|
||||
"手工报价",
|
||||
"附件",
|
||||
"无匹配舱位",
|
||||
"无匹配报价",
|
||||
"运价库无匹配",
|
||||
"标准运价库无匹配",
|
||||
)
|
||||
):
|
||||
return air_lock_need_text_quote(
|
||||
work_order_no=work_order_no, operator=operator, reason=why
|
||||
)
|
||||
lines = [
|
||||
f"工单{work_order_no} 还不能锁舱。",
|
||||
f"操作人:{operator}",
|
||||
|
||||
@@ -18,6 +18,7 @@ from agent.llm.extract_text import extract_collab_fields, extract_product_quote
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.multi_segments import Segment, group_brief, includes_air, segments_from_payload
|
||||
from agent.policy.sea_group_ops import (
|
||||
_quote_currency_hint,
|
||||
apply_group_deal,
|
||||
group_client_of,
|
||||
is_sales,
|
||||
@@ -152,7 +153,22 @@ def handle_multi_inbound(
|
||||
ticket = _reload(flow, no) or ticket
|
||||
segments = load_segments(ticket)
|
||||
target = next((seg for seg in segments if seg.mode == mode), target)
|
||||
quote = extract_product_quote(text or "", injected=injected_quote)
|
||||
# 段报价币种优先;否则用工单顶层 quote(产品未写币种时继承 TMS)
|
||||
baseline = ""
|
||||
if target is not None:
|
||||
baseline = _quote_currency_hint((segment_quotes(ticket) or {}).get(target.mode) or {})
|
||||
if not baseline:
|
||||
baseline = _quote_currency_hint(dict(ticket_view(ticket).get("quote") or {}))
|
||||
quote = extract_product_quote(
|
||||
text or "",
|
||||
injected=injected_quote,
|
||||
business_line=str(mode or "").upper(),
|
||||
transport_mode=(
|
||||
"陆运" if str(mode or "").upper() == "LAND"
|
||||
else ("空运" if str(mode or "").upper() == "AIR" else "海运")
|
||||
),
|
||||
baseline_currency=baseline,
|
||||
)
|
||||
if not quote:
|
||||
if fields:
|
||||
return "multi_collab_saved"
|
||||
|
||||
@@ -385,6 +385,7 @@ def group_brief(
|
||||
|
||||
结构对齐私聊核对:@销售和各段产品,下一行直接接段1询价字段。
|
||||
不写 TMS,不写协同段。工单号已在私聊提示过,群里不再重复一行。
|
||||
每次拼这段字段都在底部加另报提示;成交跟进是另一条消息,不加。
|
||||
"""
|
||||
_ = work_order_no
|
||||
names: list[str] = []
|
||||
@@ -406,6 +407,8 @@ def group_brief(
|
||||
body = _segment_inquiry_block(seg).strip()
|
||||
if body:
|
||||
lines.extend(body.splitlines())
|
||||
# 紧挨最后一行字段,中间不空行。含空运与不含空运用不同开头。
|
||||
lines.append(copy.multi_group_field_hint(includes_air=includes_air(segments)))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -929,19 +929,28 @@ def apply_product_quote(
|
||||
merged = dict(sess.collab_facts)
|
||||
merged.update(fields)
|
||||
sess.collab_facts = merged
|
||||
quote = extract_product_quote(text, injected=injected_quote)
|
||||
line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper()
|
||||
base = _current_quote(view, sess)
|
||||
quote = extract_product_quote(
|
||||
text,
|
||||
injected=injected_quote,
|
||||
business_line=line,
|
||||
transport_mode="陆运" if line == "LAND" else ("海运" if line == "SEA" else ""),
|
||||
baseline_currency=_quote_currency_hint(base),
|
||||
)
|
||||
if not quote:
|
||||
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
|
||||
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
||||
return "quote_not_recognized"
|
||||
quote = _attach_land_weight_band_segment(quote, view)
|
||||
base = _current_quote(view, sess)
|
||||
if _has_tms_quote(base):
|
||||
_park_pending_quote(no, quote, sess, flow)
|
||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||
return "wait_tms_choice"
|
||||
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
|
||||
# 旧错默认「海运费」的陆运产品价:重报时按本单默认运费对齐后再盖,避免海运费+陆运费并排。
|
||||
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
|
||||
base = _align_wrong_default_freight(base, quote, business_line=line)
|
||||
quote = merge_quote_fees(base, quote)
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||
@@ -1296,7 +1305,13 @@ def apply_group_deal(
|
||||
send_group_text(
|
||||
client,
|
||||
chat_id,
|
||||
copy.deal_ack(outcome, no, str(result.get("next_followup_at") or ""), lost_reason=ack_reason),
|
||||
copy.deal_ack(
|
||||
outcome,
|
||||
no,
|
||||
str(result.get("next_followup_at") or ""),
|
||||
lost_reason=ack_reason,
|
||||
followup_hours=result.get("negotiate_hours"),
|
||||
),
|
||||
)
|
||||
return phase
|
||||
|
||||
@@ -1397,6 +1412,53 @@ def _current_quote(view: dict[str, Any], sess: Any) -> dict[str, Any]:
|
||||
return base
|
||||
|
||||
|
||||
def _quote_currency_hint(quote: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
当前报价币种提示:顶层 currency → 金额前缀 → 费用行/线路段。
|
||||
|
||||
产品改价未写币种时继承此值,禁止默认 USD(WO202610090107 中港 HKD)。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
for key in ("currency", "currencyCode"):
|
||||
code = str(src.get(key) or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
for key in ("cost", "total", "freight", "land_freight"):
|
||||
raw = str(src.get(key) or "").strip().upper()
|
||||
for prefix in ("HKD", "CNY", "USD", "EUR", "JPY", "GBP", "RMB"):
|
||||
if raw.startswith(prefix):
|
||||
return "CNY" if prefix == "RMB" else prefix
|
||||
for row in _quote_fee_rows(src):
|
||||
code = str(row.get("currency") or row.get("currencyCode") or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
for key in ("landSegments", "route_rows", "selected_land_route"):
|
||||
raw = src.get(key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
import json
|
||||
|
||||
raw = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(raw, dict):
|
||||
legs = raw.get("legs") if isinstance(raw.get("legs"), list) else [raw]
|
||||
for leg in legs:
|
||||
if not isinstance(leg, dict):
|
||||
continue
|
||||
code = str(leg.get("currency") or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
elif isinstance(raw, list):
|
||||
for leg in raw:
|
||||
if not isinstance(leg, dict):
|
||||
continue
|
||||
code = str(leg.get("currency") or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
return ""
|
||||
|
||||
|
||||
def _has_tms_quote(quote: dict[str, Any] | None) -> bool:
|
||||
"""当前价来自 TMS 才要问;产品价、销售调价不再替用户选。"""
|
||||
src = dict(quote or {})
|
||||
@@ -1627,6 +1689,45 @@ def _merged_total(
|
||||
return str(src.get("total") or ""), header_ccy
|
||||
|
||||
|
||||
def _align_wrong_default_freight(
|
||||
base: dict[str, Any] | None,
|
||||
overlay: dict[str, Any] | None,
|
||||
*,
|
||||
business_line: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
陆运/空运旧产品价若误落成别的运输方式默认费名,重报前改成与新报价同名,便于按行盖价。
|
||||
|
||||
只动「海运费/陆运费/空运费/运输费」这类主运费默认名,不碰操作费等真实分项。
|
||||
"""
|
||||
src = dict(base or {})
|
||||
line = (business_line or "").strip().upper()
|
||||
want = {"LAND": "陆运费", "AIR": "空运费", "SEA": "海运费"}.get(line, "")
|
||||
if not want:
|
||||
return src
|
||||
new_names = {
|
||||
str(x.get("name") or "").strip()
|
||||
for x in (overlay or {}).get("fee_rows") or (overlay or {}).get("fee_lines") or []
|
||||
if isinstance(x, dict)
|
||||
}
|
||||
if want not in new_names:
|
||||
return src
|
||||
mains = {"海运费", "陆运费", "空运费", "运输费"}
|
||||
rows = _quote_fee_rows(src)
|
||||
changed = False
|
||||
for row in rows:
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name in mains and name != want:
|
||||
row["name"] = want
|
||||
changed = True
|
||||
if not changed:
|
||||
return src
|
||||
out = dict(src)
|
||||
out["fee_rows"] = rows
|
||||
out["fee_lines"] = rows
|
||||
return out
|
||||
|
||||
|
||||
def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""
|
||||
产品按行改价盖到原 TMS 明细上。没点名的费用原样保留。
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
空运增值服务。
|
||||
|
||||
本文件职责:把销售原话收成多项 added_services,供确认、工单和 TMS 的 addedServices 使用。
|
||||
12 个服务词只要完整出现就记;名单外的必须写在「增值服务」后面才记,并保留原词。
|
||||
包装类型/包装方式这一栏里的「托盘」只算包装,不抄进增值服务。
|
||||
「增值服务:」同一行为空时,不把下一行收进来,也不沿用上一句已经记下的服务。
|
||||
不挡查价。不改六态。海运、陆运不走这里。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from agent.schema.field_validate import _PACK_WORDS, _without_clarify_examples
|
||||
|
||||
# 长词在前,避免「卡转低消」再拆出「卡转」。
|
||||
_CN_TERMS = (
|
||||
"卡转低消",
|
||||
"前置仓",
|
||||
"托盘",
|
||||
"带电",
|
||||
"卡转",
|
||||
"备装",
|
||||
"分泡",
|
||||
"温控",
|
||||
"快件",
|
||||
)
|
||||
_CN_RE = re.compile("|".join(re.escape(word) for word in _CN_TERMS))
|
||||
_MUST_GO_RE = re.compile(r"(?i)MUST\s*GO")
|
||||
_DG_RE = re.compile(r"(?i)(?<![A-Za-z])DG(?![A-Za-z])")
|
||||
_TC_RE = re.compile(r"(?i)(?<![A-Za-z])TC(?![A-Za-z])")
|
||||
# 冒号后只吃同一行。\s 会跨行,空着的增值服务会把下一行「报价日期(非必填):」吞进来。
|
||||
_LABEL_RE = re.compile(
|
||||
r"增值服务(?:(非必填)|\(非必填\))?[^\S\n]*[::][^\S\n]*([^\n]*)"
|
||||
)
|
||||
# 包装类型/包装方式这一栏里的托盘只算包装。确认卡上增值服务留空时不能再抄一份过去。
|
||||
_PACKAGE_PALLET_RE = re.compile(
|
||||
r"包装(?:类型|方式)?(?:((?:非)?必填)|\((?:非)?必填\))?[^\S\n]*[::][^\S\n]*托盘"
|
||||
)
|
||||
_SPLIT_RE = re.compile(r"[、,,]")
|
||||
_NEG_PREFIX = frozenset("不无非")
|
||||
_FILLER = re.compile(r"[、,,\s和及与::]+")
|
||||
|
||||
|
||||
def split_added_services(raw: str) -> list[str]:
|
||||
"""
|
||||
把一栏增值服务拆成多项。
|
||||
|
||||
顿号、中文逗号、英文逗号都是分隔。空格不拆,MUST GO 仍是一项。
|
||||
副作用:无。
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for part in _SPLIT_RE.split(text):
|
||||
token = part.strip().strip("。;; ")
|
||||
key = token.casefold()
|
||||
if not token or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(token)
|
||||
return out
|
||||
|
||||
|
||||
def join_added_services(items: list[str]) -> str:
|
||||
"""多项用顿号连成销售看到的一行。空则空串。"""
|
||||
return "、".join(split_added_services("、".join(items)))
|
||||
|
||||
|
||||
def harvest_added_services(text: str) -> list[str]:
|
||||
"""
|
||||
从当前原话按出现顺序抽出增值服务。
|
||||
|
||||
补问示例行「(如:托盘、…)」整行不看,避免清单被当成已填。
|
||||
「不带电」不算带电;「卡转低消」不算再加一项卡转。
|
||||
副作用:无。
|
||||
"""
|
||||
raw = _without_clarify_examples(text or "")
|
||||
events: list[tuple[int, str]] = []
|
||||
# 包装栏里的托盘已在 _canonical_events 排除,这里只收正文里真正的服务词。
|
||||
events.extend(_canonical_events(raw))
|
||||
for match in _LABEL_RE.finditer(raw):
|
||||
value = (match.group(1) or "").strip()
|
||||
if not value or value.startswith("(如") or value.startswith("(如"):
|
||||
continue
|
||||
base = match.start(1)
|
||||
cursor = 0
|
||||
for part in _SPLIT_RE.split(value):
|
||||
piece = part.strip().strip("。;; ")
|
||||
if not piece:
|
||||
continue
|
||||
at = value.find(part, cursor)
|
||||
if at < 0:
|
||||
at = cursor
|
||||
cursor = at + len(part)
|
||||
extra = _outside_list_text(piece)
|
||||
if extra:
|
||||
events.append((base + at + max(piece.find(extra), 0), extra))
|
||||
events.sort(key=lambda item: item[0])
|
||||
return _dedupe([word for _pos, word in events])
|
||||
|
||||
|
||||
def apply_air_value_added(
|
||||
text: str,
|
||||
facts: dict[str, str] | None,
|
||||
previous: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
把本句增值服务并进事实,并避免托盘盖住已经说出的其他包装。
|
||||
|
||||
previous 是上一句已经记下的 added_services。本句没提服务时保留旧值。
|
||||
只应在空运调用。副作用:返回新字典,不改入参。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
found = harvest_added_services(text)
|
||||
labeled = _labeled_added_value(text)
|
||||
# 销售把「增值服务:」留空:这一栏就是空的。
|
||||
# 不沿用上一句,也不把包装类型里的托盘、下一行报价日期算进来。
|
||||
# 本句没写这一栏、也没提到服务时,才保留已经记下的旧值。
|
||||
if labeled == "":
|
||||
items = found
|
||||
elif found:
|
||||
items = _dedupe(split_added_services(previous) + found)
|
||||
else:
|
||||
items = _dedupe(
|
||||
split_added_services(previous)
|
||||
+ split_added_services(str(merged.get("added_services") or ""))
|
||||
)
|
||||
if items:
|
||||
merged["added_services"] = "、".join(items)
|
||||
elif not str(merged.get("added_services") or "").strip():
|
||||
merged.pop("added_services", None)
|
||||
_keep_stated_package(text, merged)
|
||||
return merged
|
||||
|
||||
|
||||
def _canonical_events(raw: str) -> list[tuple[int, str]]:
|
||||
"""按原文位置列出 12 个标准词。重叠区间只留更长的那一项。"""
|
||||
found: list[tuple[int, int, str]] = []
|
||||
package_pallets = _package_pallet_spans(raw)
|
||||
for match in _CN_RE.finditer(raw):
|
||||
word = match.group(0)
|
||||
if word == "带电" and match.start() > 0 and raw[match.start() - 1] in _NEG_PREFIX:
|
||||
continue
|
||||
if word == "托盘" and _span_inside(match.start(), match.end(), package_pallets):
|
||||
continue
|
||||
found.append((match.start(), match.end(), word))
|
||||
for match in _MUST_GO_RE.finditer(raw):
|
||||
found.append((match.start(), match.end(), "MUST GO"))
|
||||
for match in _DG_RE.finditer(raw):
|
||||
found.append((match.start(), match.end(), "DG"))
|
||||
for match in _TC_RE.finditer(raw):
|
||||
found.append((match.start(), match.end(), "TC"))
|
||||
found.sort(key=lambda item: (item[0], -(item[1] - item[0])))
|
||||
accepted: list[tuple[int, int]] = []
|
||||
events: list[tuple[int, str]] = []
|
||||
for start, end, word in found:
|
||||
if any(not (end <= left or start >= right) for left, right in accepted):
|
||||
continue
|
||||
accepted.append((start, end))
|
||||
events.append((start, word))
|
||||
return events
|
||||
|
||||
|
||||
def _outside_list_text(piece: str) -> str:
|
||||
"""
|
||||
增值服务标签里、12 个词没有覆盖到的原话。
|
||||
|
||||
「托盘」本身会被标准词扫到,这里不重复收。
|
||||
「打木架和带电」只把打木架留下来,带电仍由标准词按出现位置记。
|
||||
"""
|
||||
spans = _canonical_events(piece)
|
||||
if not spans:
|
||||
return piece.strip()
|
||||
chars = list(piece)
|
||||
for start, word in spans:
|
||||
# _canonical_events 只有起点。用标准词长度擦掉;MUST GO 用原文长度。
|
||||
end = start + _span_len(piece, start, word)
|
||||
for index in range(start, min(end, len(chars))):
|
||||
chars[index] = " "
|
||||
rest = _FILLER.sub("", "".join(chars))
|
||||
return rest.strip()
|
||||
|
||||
|
||||
def _span_len(piece: str, start: int, word: str) -> int:
|
||||
"""标准词在原文里占几位。MUST GO 中间空格可有可无。"""
|
||||
if word == "MUST GO":
|
||||
match = _MUST_GO_RE.match(piece, start)
|
||||
if match:
|
||||
return match.end() - match.start()
|
||||
if word in {"DG", "TC"}:
|
||||
return 2
|
||||
return len(word)
|
||||
|
||||
|
||||
def _labeled_added_value(text: str) -> str | None:
|
||||
"""
|
||||
原话里有没有单独的「增值服务」栏。
|
||||
|
||||
没写这一栏返回 None。写了但冒号后同一行为空,返回空串。
|
||||
只看同一行,避免空栏把下一行报价日期收进来。
|
||||
"""
|
||||
raw = _without_clarify_examples(text or "")
|
||||
seen = False
|
||||
value = ""
|
||||
for match in _LABEL_RE.finditer(raw):
|
||||
seen = True
|
||||
piece = (match.group(1) or "").strip().strip("。;; ")
|
||||
if not piece or piece.startswith("(如") or piece.startswith("(如"):
|
||||
continue
|
||||
value = piece
|
||||
if not seen:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _package_pallet_spans(raw: str) -> list[tuple[int, int]]:
|
||||
"""「包装类型:托盘」里那个托盘的起止。只算包装,不算增值服务。"""
|
||||
spans: list[tuple[int, int]] = []
|
||||
for match in _PACKAGE_PALLET_RE.finditer(raw or ""):
|
||||
end = match.end()
|
||||
spans.append((end - len("托盘"), end))
|
||||
return spans
|
||||
|
||||
|
||||
def _span_inside(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
|
||||
"""当前词是否落在给定区间里。"""
|
||||
return any(start >= left and end <= right for left, right in spans)
|
||||
|
||||
|
||||
def _keep_stated_package(text: str, facts: dict[str, str]) -> None:
|
||||
"""
|
||||
同一句里还有纸箱、木箱、卡板、散货时,包装类型保持那个词。
|
||||
|
||||
包装栏里的托盘不进增值服务,只在没另说包装时由机读记成包装类型。
|
||||
"""
|
||||
raw = _without_clarify_examples(text or "")
|
||||
if "托盘" not in raw:
|
||||
return
|
||||
last = ""
|
||||
last_pos = -1
|
||||
for word in _PACK_WORDS:
|
||||
if word == "托盘":
|
||||
continue
|
||||
pos = raw.rfind(word)
|
||||
if pos > last_pos:
|
||||
last_pos = pos
|
||||
last = word
|
||||
if last:
|
||||
facts["包装方式"] = last
|
||||
|
||||
|
||||
def _dedupe(items: list[str]) -> list[str]:
|
||||
"""按第一次出现保留,英文忽略大小写。"""
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
token = (item or "").strip()
|
||||
key = token.casefold()
|
||||
if not token or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(token)
|
||||
return out
|
||||
@@ -255,10 +255,131 @@ def option_total(option: dict[str, Any] | None, quote: dict[str, Any] | None = N
|
||||
return text
|
||||
|
||||
|
||||
# TMS 舱位「班期」可能用的键。页面只展示第一个非空值,例如 D234567,不翻译。
|
||||
_SCHEDULE_KEYS = (
|
||||
"班期",
|
||||
"schedule",
|
||||
"flightSchedule",
|
||||
"flight_schedule",
|
||||
"frequency",
|
||||
"weekFrequency",
|
||||
"scheduleDays",
|
||||
"operatingDays",
|
||||
"flightFrequency",
|
||||
"departureSchedule",
|
||||
"scheduleCode",
|
||||
"flightPeriod",
|
||||
"frequencyCode",
|
||||
)
|
||||
|
||||
|
||||
def option_flight_date(option: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
舱位上的航班日期。优先 flightDate,其次从起飞时间取日期。
|
||||
|
||||
没有则空串。不编当天。
|
||||
"""
|
||||
src = dict(option or {})
|
||||
for key in ("flightDate", "flight_date", "departureDate", "departure_date"):
|
||||
text = str(src.get(key) or "").strip()
|
||||
if text:
|
||||
return _date_head(text)
|
||||
return _date_head(str(src.get("departureTime") or src.get("departure_time") or ""))
|
||||
|
||||
|
||||
def _date_head(raw: str) -> str:
|
||||
"""yyyy-MM-dd 开头就取日期,否则原样。空串保持空。"""
|
||||
text = (raw or "").strip()
|
||||
if len(text) >= 10 and text[4] == "-" and text[7] == "-":
|
||||
return text[:10]
|
||||
return text
|
||||
|
||||
|
||||
def option_schedule(option: dict[str, Any] | None) -> str:
|
||||
"""班期按 TMS 原样,例如 D234567。没有就空串,不编。"""
|
||||
src = dict(option or {})
|
||||
for key in _SCHEDULE_KEYS:
|
||||
text = str(src.get(key) or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def option_airline(option: dict[str, Any] | None) -> str:
|
||||
"""航司:有中文名用中文名,否则用代码。没有就空串。"""
|
||||
src = dict(option or {})
|
||||
for key in ("airlineName", "airline", "airlineCode", "carrier"):
|
||||
text = str(src.get(key) or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def option_endpoint_name(
|
||||
option: dict[str, Any] | None,
|
||||
side: str,
|
||||
facts: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
舱位起运地或目的地。三字码能对上就用中文,对不上展示代码。
|
||||
|
||||
side 取 origin / destination。不编城市。
|
||||
"""
|
||||
src = dict(option or {})
|
||||
facts = facts or {}
|
||||
if side == "destination":
|
||||
code = str(src.get("destinationAirportCode") or src.get("destination") or "")
|
||||
fallback = str(facts.get("目的港") or facts.get("目的地") or "")
|
||||
else:
|
||||
code = str(src.get("originAirportCode") or src.get("origin") or "")
|
||||
fallback = str(facts.get("起运港") or facts.get("起运地") or "")
|
||||
return _place_name(code, fallback)
|
||||
|
||||
|
||||
def _price_sort_value(option: dict[str, Any]) -> float:
|
||||
"""总价排序用。解析不出的排到同一天最后。"""
|
||||
raw = option.get("salePrice")
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return float("inf")
|
||||
num = ""
|
||||
for ch in str(raw).replace(",", ""):
|
||||
if ch.isdigit() or ch == ".":
|
||||
num += ch
|
||||
elif num:
|
||||
break
|
||||
try:
|
||||
return float(num) if num else float("inf")
|
||||
except ValueError:
|
||||
return float("inf")
|
||||
|
||||
|
||||
def sort_cabin_options(options: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
没填报价日期时的舱位顺序:航班日期从早到晚,同一天总价从低到高。
|
||||
|
||||
没有日期的排在最后。不改入参。
|
||||
"""
|
||||
rows = [dict(item) for item in list(options or [])]
|
||||
|
||||
def _key(item: dict[str, Any]) -> tuple[str, float]:
|
||||
day = option_flight_date(item) or "9999-99-99"
|
||||
return (day, _price_sort_value(item))
|
||||
|
||||
return sorted(rows, key=_key)
|
||||
|
||||
|
||||
def apply_selected_option(facts: dict[str, str] | None, option: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""选定线路后写回起运港(及能对上的三字码)。改选覆盖。"""
|
||||
"""
|
||||
选定舱位后写回起运港(及能对上的三字码)。改选覆盖。
|
||||
|
||||
工单还没有报价日期时,把这条的航班日期写回去,供报价单和锁舱使用。
|
||||
销售已经说了日期则不覆盖。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
src = dict(option or {})
|
||||
flight_date = option_flight_date(src)
|
||||
if flight_date and not str(merged.get("报价日期") or "").strip():
|
||||
merged["报价日期"] = flight_date
|
||||
code = str(src.get("originAirportCode") or src.get("origin") or "").strip().upper()
|
||||
name = _place_name(code)
|
||||
if name:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
字段合同校验:读 inquiry-required-fields-v1,只做作用域/非空/默认值。
|
||||
|
||||
本文件职责:判断首次查价缺项;报价日期未说则填当天,不进入补问。
|
||||
本文件职责:判断首次查价缺项。海运、陆运报价日期未说则填当天,不进入补问。
|
||||
空运报价日期非必填:没说不填当天、不进补问,确认后不带日期查价。
|
||||
口语已写 TMS 单位(件/KGS/CBM)时,空着的件数/毛重/体积可机读补上。
|
||||
禁止:正则猜港口/箱型;禁止用封闭枚举拒绝用户原文;禁止改六态。
|
||||
"""
|
||||
@@ -37,6 +38,7 @@ AIR_OPTIONAL_FIELDS = (
|
||||
("提货地址", "提货地址"),
|
||||
("收货地址", "收货地址"),
|
||||
("贸易条款", "贸易条款"),
|
||||
("added_services", "增值服务"),
|
||||
)
|
||||
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
|
||||
AIR_DISPLAY.update({key: label for key, label in AIR_OPTIONAL_FIELDS})
|
||||
@@ -236,6 +238,9 @@ _ALIASES = {
|
||||
"贸易条款(非必填)": "贸易条款",
|
||||
"起运港(非必填)": "起运港",
|
||||
"起运地(非必填)": "起运港",
|
||||
"增值服务": "added_services",
|
||||
"增值服务(非必填)": "added_services",
|
||||
"addedServices": "added_services",
|
||||
}
|
||||
for _quote_alias in QUOTE_DATE_SYNONYMS:
|
||||
_ALIASES[_quote_alias] = "报价日期"
|
||||
@@ -645,7 +650,11 @@ def shape_facts_for_chosen_mode(facts: dict[str, str] | None, business_line: str
|
||||
|
||||
|
||||
def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]:
|
||||
"""销售未提供报价日期时填当天;已有则不覆盖。"""
|
||||
"""
|
||||
海运、陆运:销售未提供报价日期时填上海当天;已有则不覆盖。
|
||||
|
||||
空运不要走这里。空运没说日期必须保持空白,不能填成当天。
|
||||
"""
|
||||
merged = dict(facts)
|
||||
if not merged.get("报价日期"):
|
||||
merged["报价日期"] = shanghai_today()
|
||||
@@ -730,13 +739,17 @@ def validate_required_fields(
|
||||
"""
|
||||
首次查价完整性。
|
||||
|
||||
返回:ok / implemented / missing(内部键)/ missing_display / facts(含默认报价日期)/ errors。
|
||||
报价日期不进 missing。未识别业务线时 missing 为空、need_transport_mode=True。
|
||||
返回:ok / implemented / missing(内部键)/ missing_display / facts / errors。
|
||||
报价日期不进 missing。海运、陆运没说则 facts 里带上当天;空运没说则保持空白。
|
||||
未识别业务线时 missing 为空、need_transport_mode=True,此时不填日期。
|
||||
"""
|
||||
_ = require_formal
|
||||
contract = require_formal_contract("inquiry-required-fields-v1")
|
||||
normalized = peel_trailing_place_clauses(apply_quote_date_default(normalize_facts(facts)))
|
||||
normalized = peel_trailing_place_clauses(normalize_facts(facts))
|
||||
line = (business_line or "").strip().upper()
|
||||
# 只有海运、陆运把空日期补成当天。空运留给销售稍后在舱位里选定航班日期。
|
||||
if line in {"SEA", "LAND"}:
|
||||
normalized = apply_quote_date_default(normalized)
|
||||
if line == "SEA":
|
||||
normalized = project_sea_cargo_qty(normalized)
|
||||
from agent.schema.sea_options import canonicalize_sea_class
|
||||
|
||||
@@ -236,9 +236,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not pack_raw:
|
||||
missing.append("包装方式")
|
||||
missing_display.append("包装类型")
|
||||
if not quote_date:
|
||||
missing.append("报价日期")
|
||||
missing_display.append("报价日期")
|
||||
# 报价日期非必填:空着仍可查价,出站不带日期,不能补成当天。
|
||||
|
||||
ok = not missing
|
||||
out_facts = dict(src)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
空运增值服务:多项原词、示例行不当已填、传给 TMS 的是 addedServices 列表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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)
|
||||
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
|
||||
from agent.llm.extract_text import extract_inquiry_snapshot
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.schema.added_services import harvest_added_services, split_added_services
|
||||
from agent.schema.tms_air_query import assemble_air_query
|
||||
|
||||
|
||||
_EXAMPLE = "(如:托盘、前置仓、带电、卡转、卡转低消、MUST GO、备装、分泡、温控、快件、DG、TC)"
|
||||
|
||||
|
||||
class AirAddedServicesTests(unittest.TestCase):
|
||||
def test_split_enumeration_and_chinese_comma(self) -> None:
|
||||
"""顿号、中文逗号、英文逗号拆成同一组,空格不拆开 MUST GO。"""
|
||||
self.assertEqual(
|
||||
split_added_services("托盘、前置仓、带电"),
|
||||
["托盘", "前置仓", "带电"],
|
||||
)
|
||||
self.assertEqual(
|
||||
split_added_services("托盘,前置仓,带电"),
|
||||
["托盘", "前置仓", "带电"],
|
||||
)
|
||||
self.assertEqual(
|
||||
split_added_services("托盘,前置仓,带电"),
|
||||
["托盘", "前置仓", "带电"],
|
||||
)
|
||||
self.assertEqual(split_added_services("MUST GO、带电"), ["MUST GO", "带电"])
|
||||
|
||||
def test_example_line_is_not_filled(self) -> None:
|
||||
text = "增值服务(非必填):\n" + _EXAMPLE
|
||||
self.assertEqual(harvest_added_services(text), [])
|
||||
|
||||
def test_labeled_value_keeps_order_and_canonical_english(self) -> None:
|
||||
dun = harvest_added_services("增值服务(非必填):托盘、前置仓、带电")
|
||||
comma = harvest_added_services("增值服务(非必填):托盘,前置仓,带电")
|
||||
self.assertEqual(dun, ["托盘", "前置仓", "带电"])
|
||||
self.assertEqual(comma, ["托盘", "前置仓", "带电"])
|
||||
spoken = harvest_added_services("这票 must go,要dg和tc")
|
||||
self.assertEqual(spoken, ["MUST GO", "DG", "TC"])
|
||||
|
||||
def test_longest_match_and_negation(self) -> None:
|
||||
self.assertEqual(harvest_added_services("要卡转低消"), ["卡转低消"])
|
||||
self.assertNotIn("卡转", harvest_added_services("要卡转低消"))
|
||||
self.assertEqual(harvest_added_services("不带电"), [])
|
||||
self.assertEqual(harvest_added_services("带电"), ["带电"])
|
||||
|
||||
def test_outside_list_only_when_labeled(self) -> None:
|
||||
"""名单外的词必须写在增值服务后面才记;12 个词只要出现就记。"""
|
||||
self.assertEqual(harvest_added_services("要打木架"), [])
|
||||
self.assertEqual(
|
||||
harvest_added_services("增值服务:打木架"),
|
||||
["打木架"],
|
||||
)
|
||||
self.assertEqual(
|
||||
harvest_added_services("包装纸箱,要卡转低消,带电,增值服务:打木架"),
|
||||
["卡转低消", "带电", "打木架"],
|
||||
)
|
||||
|
||||
def test_extract_stores_added_services_and_keeps_other_package(self) -> None:
|
||||
snap = extract_inquiry_snapshot(
|
||||
"空运到吉隆坡,茶叶,10件,100公斤,2方,包装纸箱,要卡转低消,带电,增值服务:打木架",
|
||||
injected_mode="AIR",
|
||||
)
|
||||
facts = snap["facts"]
|
||||
self.assertEqual(facts.get("added_services"), "卡转低消、带电、打木架")
|
||||
self.assertEqual(facts.get("包装方式"), "纸箱")
|
||||
self.assertEqual(facts.get("是否含电"), "是")
|
||||
|
||||
def test_pallet_does_not_replace_another_package(self) -> None:
|
||||
snap = extract_inquiry_snapshot(
|
||||
"空运,包装是纸箱,要托盘",
|
||||
injected_mode="AIR",
|
||||
)
|
||||
facts = snap["facts"]
|
||||
self.assertEqual(facts.get("包装方式"), "纸箱")
|
||||
self.assertEqual(facts.get("added_services"), "托盘")
|
||||
|
||||
def test_blank_service_line_does_not_copy_package_or_next_field(self) -> None:
|
||||
"""包装类型写托盘、增值服务留空时,不能把托盘和下一行报价日期抄进增值服务。"""
|
||||
text = (
|
||||
"运输方式:空运\n"
|
||||
"起运地:-\n"
|
||||
"目的地:CRK\n"
|
||||
"品名:LMM振动测试定位治具\n"
|
||||
"件数:1件\n"
|
||||
"重量(KG):1.5KG\n"
|
||||
"体积(CBM):100CBM\n"
|
||||
"包装类型:托盘\n"
|
||||
"增值服务:\n"
|
||||
"报价日期(非必填):"
|
||||
)
|
||||
self.assertEqual(harvest_added_services(text), [])
|
||||
snap = extract_inquiry_snapshot(text)
|
||||
self.assertNotIn("added_services", snap["facts"])
|
||||
self.assertEqual(snap["facts"].get("包装方式"), "托盘")
|
||||
card = copy.air_confirm_card(snap["facts"])
|
||||
self.assertIn("包装类型(必填):托盘", card)
|
||||
self.assertIn("增值服务(非必填):\n", card)
|
||||
self.assertNotIn("托盘、报价日期", card)
|
||||
|
||||
def test_pallet_alone_is_package_and_service(self) -> None:
|
||||
snap = extract_inquiry_snapshot(
|
||||
"空运,不带电,包装托盘,目的地吉隆坡",
|
||||
injected_mode="AIR",
|
||||
)
|
||||
facts = snap["facts"]
|
||||
self.assertEqual(facts.get("包装方式"), "托盘")
|
||||
self.assertEqual(facts.get("added_services"), "托盘")
|
||||
self.assertNotIn("带电", facts.get("added_services") or "")
|
||||
self.assertEqual(facts.get("是否含电"), "否")
|
||||
|
||||
def test_confirm_lists_optional_and_ticket_hides_empty(self) -> None:
|
||||
card = copy.air_confirm_card({"目的港": "吉隆坡", "包装方式": "纸箱"})
|
||||
self.assertIn("增值服务(非必填):", card)
|
||||
clarify = copy.ask_clarify(
|
||||
facts={"目的港": "吉隆坡"},
|
||||
missing_keys=["品名"],
|
||||
transport_mode="空运",
|
||||
)
|
||||
self.assertIn("增值服务(非必填):\n" + _EXAMPLE, clarify)
|
||||
lines = copy.inquiry_field_lines(
|
||||
work_order_no="WO1",
|
||||
facts={"目的港": "吉隆坡", "包装方式": "纸箱", "added_services": "托盘、带电"},
|
||||
transport_mode="空运",
|
||||
)
|
||||
self.assertIn("增值服务:托盘、带电", lines)
|
||||
empty = copy.inquiry_field_lines(
|
||||
work_order_no="WO1",
|
||||
facts={"目的港": "吉隆坡", "包装方式": "纸箱"},
|
||||
transport_mode="空运",
|
||||
)
|
||||
self.assertFalse(any(line.startswith("增值服务") for line in empty))
|
||||
|
||||
def test_assemble_keeps_services_without_blocking(self) -> None:
|
||||
ready = {
|
||||
"目的港": "吉隆坡",
|
||||
"品名": "茶叶",
|
||||
"件数": "10",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "纸箱",
|
||||
"报价日期": "2026-10-13",
|
||||
"added_services": "托盘、前置仓、带电",
|
||||
}
|
||||
out = assemble_air_query(ready)
|
||||
self.assertTrue(out["ok"], out)
|
||||
self.assertEqual(out["facts"]["added_services"], "托盘、前置仓、带电")
|
||||
bare = dict(ready)
|
||||
bare.pop("added_services")
|
||||
self.assertTrue(assemble_air_query(bare)["ok"])
|
||||
self.assertNotIn("added_services", assemble_air_query(bare)["facts"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1476,6 +1476,8 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertEqual(ticket.quote.get("quoteId"), "QT-AIR-1")
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
|
||||
self.assertTrue(any("如需锁舱,请航线人员改用纯文字报价" in t for t in self._texts()))
|
||||
self.assertTrue(any("空运费 单价CNY99.00" in t for t in self._texts()))
|
||||
self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts()))
|
||||
|
||||
def test_reactivate_after_airline_file_is_deal_not_blank_sheet(self) -> None:
|
||||
|
||||
@@ -87,6 +87,7 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
"报价日期": "2026-09-16",
|
||||
"tms_multi": "1",
|
||||
}
|
||||
phase = flow.on_text(
|
||||
@@ -144,6 +145,7 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
"毛重": "21",
|
||||
"体积": "1",
|
||||
"包装方式": "散货",
|
||||
"报价日期": "2026-09-16",
|
||||
}
|
||||
self.assertEqual(
|
||||
flow.on_text(sender_id="u_hgh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"),
|
||||
@@ -174,6 +176,7 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
"报价日期": "2026-09-16",
|
||||
"tms_multi": "1",
|
||||
}
|
||||
flow.on_text(sender_id="u_zuh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR")
|
||||
@@ -199,6 +202,7 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
"报价日期": "2026-09-16",
|
||||
"tms_multi": "1",
|
||||
}
|
||||
phase = flow.on_text(
|
||||
@@ -413,6 +417,118 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
self.assertIn("window.close()", desktop)
|
||||
self.assertNotIn("closeWindow", dead)
|
||||
|
||||
def test_blank_date_always_opens_cabin_page_and_writes_flight_date(self) -> None:
|
||||
"""没填日期:1 条舱位也要进 H5;选定后用该条总价,航班日期写回,不再查价。"""
|
||||
from agent.schema.air_route_options import sort_cabin_options
|
||||
|
||||
later = {
|
||||
"originAirportCode": "PVG",
|
||||
"destinationAirportCode": "LAX",
|
||||
"flightDate": "2026-10-21",
|
||||
"schedule": "D1",
|
||||
"airlineName": "东航",
|
||||
"salePrice": 1000,
|
||||
"currency": "CNY",
|
||||
}
|
||||
earlier = {
|
||||
"originAirportCode": "PVG",
|
||||
"destinationAirportCode": "LAX",
|
||||
"flightDate": "2026-10-20",
|
||||
"schedule": "D234567",
|
||||
"airlineName": "南航",
|
||||
"salePrice": 3200,
|
||||
"currency": "CNY",
|
||||
}
|
||||
ordered = sort_cabin_options([later, earlier])
|
||||
self.assertEqual(option_flight_date_of(ordered[0]), "2026-10-20")
|
||||
|
||||
class Ledger(MemoryLedger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.queries = 0
|
||||
|
||||
def query_tms(self, **kwargs):
|
||||
self.queries += 1
|
||||
out = super().query_tms(**kwargs)
|
||||
out["quote"]["airOptions"] = [later, earlier]
|
||||
return out
|
||||
|
||||
ledger = Ledger()
|
||||
flow = AirTextInquiryFlow(ledger)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> None:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"件数": "3",
|
||||
"毛重": "180",
|
||||
"体积": "2.5",
|
||||
"包装方式": "纸箱",
|
||||
}
|
||||
self.assertEqual(
|
||||
flow.on_text(sender_id="u_cabin", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"),
|
||||
"wait_confirm",
|
||||
)
|
||||
self.assertIn("报价日期(非必填):", replies[-1])
|
||||
self.assertNotIn("报价日期:2026", replies[-1])
|
||||
self.assertEqual(flow.on_text(sender_id="u_cabin", text="确定", reply=reply), "wait_air_route")
|
||||
self.assertEqual(ledger.queries, 1)
|
||||
self.assertTrue(any("条舱位" in x for x in replies))
|
||||
self.assertFalse(any("条线路" in x for x in replies))
|
||||
self.assertEqual(flow.select_air_route(sender_id="u_cabin", index=0, reply=reply), "wait_collab")
|
||||
self.assertEqual(ledger.queries, 1)
|
||||
sess = flow.session_of("u_cabin")
|
||||
self.assertEqual(sess.facts.get("报价日期"), "2026-10-20")
|
||||
self.assertIn("3200", str(sess.quote.get("total")))
|
||||
|
||||
def test_cabin_page_shows_six_fields_and_total_only(self) -> None:
|
||||
html = render_air_route_page(
|
||||
{
|
||||
"cabin_pick": True,
|
||||
"work_order_no": "WO202610130001",
|
||||
"facts": {},
|
||||
"quote": {
|
||||
"airOptions": [
|
||||
{
|
||||
"originAirportCode": "PVG",
|
||||
"destinationAirportCode": "LAX",
|
||||
"flightDate": "2026-10-20",
|
||||
"schedule": "D234567",
|
||||
"airlineName": "南航",
|
||||
"salePrice": 3200,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [
|
||||
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 11}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertIn("选择舱位", html)
|
||||
self.assertIn("起运地", html)
|
||||
self.assertIn("目的地", html)
|
||||
self.assertIn("航班日期", html)
|
||||
self.assertIn("2026-10-20", html)
|
||||
self.assertIn("班期", html)
|
||||
self.assertIn("D234567", html)
|
||||
self.assertIn("航司", html)
|
||||
self.assertIn("南航", html)
|
||||
self.assertIn("3200", html)
|
||||
self.assertNotIn("运输费", html)
|
||||
self.assertNotIn("userid", html.lower())
|
||||
|
||||
|
||||
def option_flight_date_of(option: dict) -> str:
|
||||
from agent.schema.air_route_options import option_flight_date
|
||||
|
||||
return option_flight_date(option)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -140,11 +140,16 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_quote_date_defaults_today_not_in_missing(self) -> None:
|
||||
def test_air_quote_date_stays_blank_and_not_in_missing(self) -> None:
|
||||
"""空运没说报价日期不填当天,也不拿来补问。海运、陆运仍默认当天。"""
|
||||
result = validate_required_fields(facts=COMPLETE_AIR, business_line="AIR")
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertTrue(result["facts"]["报价日期"])
|
||||
self.assertFalse(result["facts"].get("报价日期"))
|
||||
self.assertNotIn("报价日期", result["missing"])
|
||||
sea = validate_required_fields(facts={"起运港": "上海"}, business_line="SEA")
|
||||
self.assertTrue(sea["facts"].get("报价日期"))
|
||||
land = validate_required_fields(facts={"运输类型": "国内整车"}, business_line="LAND")
|
||||
self.assertTrue(land["facts"].get("报价日期"))
|
||||
|
||||
def test_fly_is_air_and_followup_keeps_mode(self) -> None:
|
||||
phase = self._on_text(
|
||||
@@ -284,7 +289,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("目的地", last)
|
||||
self.assertIn("件数", last)
|
||||
self.assertIn("如:", last)
|
||||
self.assertIn("报价日期:", last)
|
||||
self.assertIn("报价日期(非必填):", last)
|
||||
self.assertNotIn("报价日期:(如", last)
|
||||
|
||||
def test_clarify_paste_reply_fills_missing_required(self) -> None:
|
||||
|
||||
@@ -75,13 +75,14 @@ class BotReplyRenderTests(unittest.TestCase):
|
||||
override_catalog(
|
||||
{
|
||||
"negotiating.recorded": (
|
||||
"已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。"
|
||||
"已记录为「协商中」。已顺延 {{followup_hours}} 个工作小时,"
|
||||
"将于 {{followup_when}} 再次发起成交状态询问。"
|
||||
)
|
||||
}
|
||||
)
|
||||
text = copy.deal_ack("协商中", "WO1", next_followup_at="")
|
||||
self.assertIn("第四个工作日", text)
|
||||
self.assertIn("9:00", text)
|
||||
text = copy.deal_ack("协商中", "WO1", next_followup_at="", followup_hours=48)
|
||||
self.assertIn("48", text)
|
||||
self.assertIn("工作小时", text)
|
||||
|
||||
def test_group_add_fail_empty_names_reads_admin(self) -> None:
|
||||
override_catalog(
|
||||
|
||||
@@ -10,6 +10,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from agent.policy.deal_followup import (
|
||||
SHANGHAI,
|
||||
add_workday_hours,
|
||||
air_route_keys,
|
||||
is_workday,
|
||||
leader_matches,
|
||||
@@ -41,12 +42,18 @@ class TestWorkdays(unittest.TestCase):
|
||||
self.assertTrue(is_workday(date(2026, 9, 20)))
|
||||
self.assertFalse(is_workday(date(2026, 10, 7)))
|
||||
|
||||
def test_next_followup_is_9am(self) -> None:
|
||||
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
|
||||
self.assertEqual(when.hour, 9)
|
||||
self.assertEqual(when.minute, 0)
|
||||
def test_next_followup_48_work_hours_skips_weekend(self) -> None:
|
||||
# 周五 15:30 + 48 工作小时 → 跳过周末 → 周二 15:30
|
||||
when = next_followup_at(
|
||||
datetime(2026, 11, 13, 15, 30, tzinfo=SHANGHAI), hours=48
|
||||
)
|
||||
self.assertEqual(when.tzinfo, SHANGHAI)
|
||||
self.assertEqual(when.date(), date(2026, 9, 23))
|
||||
self.assertEqual(when, datetime(2026, 11, 17, 15, 30, tzinfo=SHANGHAI))
|
||||
|
||||
def test_add_workday_hours_from_rest_day(self) -> None:
|
||||
# 周六起点:从下周一 00:00 起算 10 小时 → 周一 10:00
|
||||
when = add_workday_hours(datetime(2026, 11, 14, 10, 0, tzinfo=SHANGHAI), 10)
|
||||
self.assertEqual(when, datetime(2026, 11, 16, 10, 0, tzinfo=SHANGHAI))
|
||||
|
||||
def test_skip_weekend_keep_weekday_night(self) -> None:
|
||||
start = datetime(2026, 11, 13, 17, 0, tzinfo=SHANGHAI) # 周五
|
||||
|
||||
@@ -277,15 +277,22 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertNotIn("请补充未成交原因", joined)
|
||||
|
||||
def test_negotiate_ack_names_followup_day(self) -> None:
|
||||
text = copy.deal_ack("协商中", "WO202609190007", "2026-09-24 09:00:00")
|
||||
text = copy.deal_ack(
|
||||
"协商中",
|
||||
"WO202609190007",
|
||||
"2026-09-24 15:30:00",
|
||||
followup_hours=48,
|
||||
)
|
||||
self.assertEqual(
|
||||
text,
|
||||
"已记录为「协商中」。已顺延三个工作日,9月24日的9:00再次发起成交状态询问。",
|
||||
"已记录为「协商中」。已顺延 48 个工作小时,将于 9月24日15:30 再次发起成交状态询问。",
|
||||
)
|
||||
self.assertNotIn("跟进周期已顺延", text)
|
||||
self.assertNotIn("三个工作日", text)
|
||||
|
||||
def test_negotiate_keeps_wait_deal(self) -> None:
|
||||
self.ledger.start_event_wait(work_order_no=self.wo, wait_kind="deal")
|
||||
self.ledger._timeouts["deal_negotiate"] = 48
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
@@ -295,6 +302,7 @@ class TestDealOutcome(unittest.TestCase):
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual(out.get("phase"), "wait_deal")
|
||||
self.assertEqual(out.get("negotiate_hours"), 48)
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "协商中")
|
||||
self.assertEqual(ticket.event_wait_kind, "")
|
||||
@@ -313,6 +321,22 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "协商中")
|
||||
self.assertTrue(again.get("next_followup_at"))
|
||||
|
||||
def test_negotiate_zero_hours_skips_schedule(self) -> None:
|
||||
"""后台把 deal_negotiate 设为 0:仍记协商中,但不排再问时刻。"""
|
||||
self.ledger._timeouts["deal_negotiate"] = 0
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="协商中",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual(out.get("phase"), "wait_deal")
|
||||
self.assertEqual(out.get("negotiate_hours"), 0)
|
||||
self.assertEqual(out.get("next_followup_at"), "")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).next_followup_at, "")
|
||||
|
||||
def test_private_next_text_is_lost_reason(self) -> None:
|
||||
flow = AirTextInquiryFlow(self.ledger)
|
||||
sess = FlowSession(
|
||||
|
||||
@@ -360,6 +360,84 @@ class LandGroupCollabTests(unittest.TestCase):
|
||||
"只有这张单的销售能调价。航线请继续报价,价格未改。",
|
||||
)
|
||||
|
||||
def test_land_bare_price_defaults_to_land_fee(self) -> None:
|
||||
"""陆运「价格为1RMB/KG」无费用名时默认陆运费,禁止落成海运费(WO118)。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
land = extract_product_quote("价格为1RMB/KG", business_line="LAND")
|
||||
rows = list(land.get("fee_rows") or [])
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual(str(rows[0].get("name") or ""), "陆运费")
|
||||
self.assertEqual(str(land.get("total") or ""), "1")
|
||||
sea = extract_product_quote("价格为1RMB/KG", business_line="SEA")
|
||||
self.assertEqual(str((sea.get("fee_rows") or [{}])[0].get("name") or ""), "海运费")
|
||||
air = extract_product_quote("价格为1RMB/KG", business_line="AIR")
|
||||
self.assertEqual(str((air.get("fee_rows") or [{}])[0].get("name") or ""), "空运费")
|
||||
|
||||
def test_land_adjust_without_currency_inherits_tms_hkd(self) -> None:
|
||||
"""WO202610090107:TMS 港币,产品只写「运输费调整为1100」时继承 HKD,禁止默认 USD。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
from agent.policy.sea_group_ops import _quote_currency_hint, merge_quote_fees
|
||||
|
||||
tms = {
|
||||
"currency": "HKD",
|
||||
"cost": "HKD 1000",
|
||||
"total": "1000",
|
||||
"source": "TMS",
|
||||
"fee_lines": [
|
||||
{
|
||||
"name": "运输费",
|
||||
"amount": "1000",
|
||||
"currency": "HKD",
|
||||
}
|
||||
],
|
||||
"landSegments": [
|
||||
{
|
||||
"origin": "广东省-深圳市-福田区",
|
||||
"destination": "香港特别行政区",
|
||||
"currency": "HKD",
|
||||
"costPrice": "1000",
|
||||
}
|
||||
],
|
||||
}
|
||||
self.assertEqual(_quote_currency_hint(tms), "HKD")
|
||||
# 旧行为:无 baseline 会落到 USD
|
||||
legacy = extract_product_quote("运输费调整为1100", business_line="LAND")
|
||||
self.assertNotEqual(str(legacy.get("currency") or ""), "USD")
|
||||
quote = extract_product_quote(
|
||||
"运输费调整为1100",
|
||||
business_line="LAND",
|
||||
baseline_currency=_quote_currency_hint(tms),
|
||||
)
|
||||
self.assertEqual(str(quote.get("currency") or ""), "HKD")
|
||||
self.assertEqual(str((quote.get("fee_lines") or [{}])[0].get("currency") or ""), "HKD")
|
||||
self.assertEqual(str((quote.get("fee_lines") or [{}])[0].get("amount") or ""), "1100")
|
||||
merged = merge_quote_fees(tms, quote)
|
||||
self.assertEqual(str((merged.get("fee_lines") or [{}])[0].get("currency") or ""), "HKD")
|
||||
self.assertEqual(str(merged.get("currency") or ""), "HKD")
|
||||
|
||||
def test_land_requote_replaces_wrong_sea_fee_name(self) -> None:
|
||||
"""旧陆运产品价误落海运费时,重报陆运费应盖掉而不是并排两行。"""
|
||||
from agent.policy.sea_group_ops import _align_wrong_default_freight, merge_quote_fees
|
||||
|
||||
base = {
|
||||
"fee_rows": [{"name": "海运费", "amount": "1", "currency": "CNY"}],
|
||||
"fee_lines": [{"name": "海运费", "amount": "1", "currency": "CNY"}],
|
||||
"total": "1",
|
||||
"source_label": "产品报价",
|
||||
}
|
||||
overlay = {
|
||||
"fee_rows": [{"name": "陆运费", "amount": "2", "currency": "CNY"}],
|
||||
"fee_lines": [{"name": "陆运费", "amount": "2", "currency": "CNY"}],
|
||||
"total": "2",
|
||||
"source_label": "产品报价",
|
||||
}
|
||||
aligned = _align_wrong_default_freight(base, overlay, business_line="LAND")
|
||||
merged = merge_quote_fees(aligned, overlay)
|
||||
names = [str(x.get("name") or "") for x in (merged.get("fee_rows") or [])]
|
||||
self.assertEqual(names, ["陆运费"])
|
||||
self.assertEqual(str((merged.get("fee_rows") or [{}])[0].get("amount") or ""), "2")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -433,6 +433,41 @@ class LandRouteOptionsTests(unittest.TestCase):
|
||||
self.assertIn("CNY 7500", page)
|
||||
self.assertIn("成本价合计", page)
|
||||
|
||||
def test_h5_shows_vehicle_type_on_each_leg(self) -> None:
|
||||
"""多条线路的 H5 卡片按段展示 TMS 车型,没有就写 -。"""
|
||||
from agent.channel.h5.land_route_page import render_land_route_page
|
||||
|
||||
quote = _three_routes()
|
||||
for leg in quote["landSegments"]:
|
||||
dest = str(leg.get("destination") or "")
|
||||
origin = str(leg.get("origin") or "")
|
||||
if dest == "深圳":
|
||||
leg["vehicleType"] = "9米6"
|
||||
elif origin == "深圳":
|
||||
leg["vehicleType"] = "13米"
|
||||
elif dest == "广西":
|
||||
leg["vehicleType"] = None
|
||||
quote["fee_lines"] = _tms_land_fees(
|
||||
(1, 1, "运输费", 7500),
|
||||
(1, 2, "运输费", 9600),
|
||||
(2, 1, "运输费", 7500),
|
||||
(2, 2, "运输费", 9600),
|
||||
(3, 1, "运输费", 7500),
|
||||
(3, 2, "运输费", 9600),
|
||||
)
|
||||
page = render_land_route_page(
|
||||
{"work_order_no": "WO202609220010", "quote": quote}
|
||||
)
|
||||
od = page.find("上海 → 深圳")
|
||||
vehicle = page.find("车型 9米6", od)
|
||||
fee = page.find("运输费", vehicle)
|
||||
self.assertGreaterEqual(od, 0)
|
||||
self.assertGreater(vehicle, od)
|
||||
self.assertGreater(fee, vehicle)
|
||||
self.assertIn("车型 13米", page)
|
||||
self.assertIn("车型 -", page)
|
||||
self.assertNotIn("9米6/1", page)
|
||||
|
||||
def test_h5_and_detail_use_tms_fees_when_java_sends_json_strings(self) -> None:
|
||||
"""主账 quote 常是 JSON 串,且 fee_lines 可能没带段号。"""
|
||||
from agent.channel.h5.land_route_page import render_land_route_page
|
||||
|
||||
@@ -177,6 +177,7 @@ class QuoteTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "multi_deal_sent")
|
||||
self.assertIn("120", self.client.texts[-1])
|
||||
self.assertNotIn("纯文字", self.client.texts[-1])
|
||||
self.assertNotIn("100", deal_text(
|
||||
work_order_no=self.ticket.work_order_no,
|
||||
segments=[Segment(index=1, mode="SEA"), Segment(index=2, mode="LAND")],
|
||||
@@ -198,6 +199,8 @@ class QuoteTests(unittest.TestCase):
|
||||
brief = self.client.texts[-1]
|
||||
self.assertIn("段1:海运", brief)
|
||||
self.assertIn("段2:陆运", brief)
|
||||
self.assertIn("产品人员可通过“纯文字”告知新报价", brief)
|
||||
self.assertNotIn("航线或产品人员", brief)
|
||||
self.assertNotIn("TMS报价", brief)
|
||||
self.assertNotIn("其他段报完", brief)
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=no).collab_chat_id, "room-9")
|
||||
@@ -265,6 +268,8 @@ class QuoteTests(unittest.TestCase):
|
||||
self.assertIn("段2:空运", brief)
|
||||
self.assertIn("目的地(必填):CRK", brief)
|
||||
self.assertIn("贸易条款(非必填):DAP", brief)
|
||||
self.assertTrue(brief.endswith("(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"))
|
||||
self.assertIn("航线或产品人员可通过“纯文字”告知新报价", brief)
|
||||
self.assertNotIn("其他段报完", "\n".join(self.client.texts))
|
||||
self.assertNotIn("询价字段:", brief)
|
||||
|
||||
|
||||
@@ -167,6 +167,10 @@ class SplitTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("@吴季林@林绵助\n段1:", text)
|
||||
self.assertNotIn("@林绵助\n\n段1", text)
|
||||
self.assertTrue(
|
||||
text.endswith(copy.multi_group_field_hint(includes_air=True)),
|
||||
text,
|
||||
)
|
||||
|
||||
def test_missing_fields_use_single_chat_examples(self) -> None:
|
||||
outcome = split_segments("段1:海运\n起运港:广州港\n段2:空运\n目的地:CRK")
|
||||
@@ -694,6 +698,11 @@ class PullTests(unittest.TestCase):
|
||||
segments=flow._segments(flow.session_of("sales")),
|
||||
)
|
||||
self.assertNotIn("TMS", brief_text)
|
||||
self.assertTrue(
|
||||
brief_text.endswith(copy.multi_group_field_hint(includes_air=False)),
|
||||
brief_text,
|
||||
)
|
||||
self.assertNotIn("航线或产品人员", brief_text)
|
||||
|
||||
|
||||
class CardPullTests(unittest.TestCase):
|
||||
|
||||
@@ -1376,8 +1376,24 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
self.assertEqual(str(rows["空运费"].get("amount") or ""), "199.00")
|
||||
self.assertEqual(str(rows["空运费"].get("currency") or ""), "CNY")
|
||||
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
|
||||
self.assertEqual(str(rows["空运费"].get("quantity") or ""), "100")
|
||||
self.assertEqual(str(quote.get("total") or ""), "199.0")
|
||||
|
||||
def test_air_fee_priced_with_unit_between_name_and_unit_price(self) -> None:
|
||||
"""WO003:空运费, KG, 单价…数量…金额… 中间夹单位时不能把单价当行金额。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"空运段: 空运费, KG, 单价CNY99.00, 数量100, 金额CNY9900.00@询价小助手",
|
||||
business_line="AIR",
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("空运费", rows)
|
||||
self.assertEqual(str(rows["空运费"].get("amount") or ""), "9900.00")
|
||||
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
|
||||
self.assertEqual(str(rows["空运费"].get("quantity") or ""), "100")
|
||||
self.assertEqual(str(quote.get("total") or ""), "9900.0")
|
||||
|
||||
def test_fee_line_text_is_a_product_quote(self) -> None:
|
||||
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
+2
@@ -387,6 +387,8 @@ public class InquiryAgentController {
|
||||
return Result.error("timeoutCode 必填");
|
||||
}
|
||||
responseTimeoutService.ensureTimeoutCodes();
|
||||
// 协商再问配置可能尚未跑 SQL 迁移:首次按码查询时幂等补种子
|
||||
responseTimeoutService.ensureDealNegotiateRow();
|
||||
org.jeecg.modules.inquiry.entity.InquiryResponseTimeout row =
|
||||
responseTimeoutService.getByTimeoutCode(code);
|
||||
Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||
|
||||
+5
-2
@@ -27,7 +27,10 @@ public class InquiryDashboardController {
|
||||
|
||||
@ApiOperation("看板指标")
|
||||
@GetMapping("/metrics")
|
||||
public Result<Map<String, Object>> metrics(@RequestParam(name = "range", defaultValue = "today") String range) {
|
||||
return Result.OK(dashboardService.metrics(range));
|
||||
public Result<Map<String, Object>> metrics(
|
||||
@RequestParam(name = "range", defaultValue = "today") String range,
|
||||
@RequestParam(name = "dateFrom", required = false) String dateFrom,
|
||||
@RequestParam(name = "dateTo", required = false) String dateTo) {
|
||||
return Result.OK(dashboardService.metrics(range, dateFrom, dateTo));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -41,6 +41,7 @@ public class InquiryResponseTimeoutController
|
||||
MODE_LABEL.put("AIR", "空运");
|
||||
MODE_LABEL.put("SEA", "海运");
|
||||
MODE_LABEL.put("LAND", "陆运");
|
||||
MODE_LABEL.put("DEAL", "成交跟进");
|
||||
ROLE_LABEL.put("sales", "销售");
|
||||
ROLE_LABEL.put("product", "产品");
|
||||
ROLE_LABEL.put("air_route", "航线");
|
||||
@@ -60,9 +61,10 @@ public class InquiryResponseTimeoutController
|
||||
@ApiOperation("全量列表(按 sort_no,供系统设置页一次加载)")
|
||||
@GetMapping("/listAll")
|
||||
public Result<List<InquiryResponseTimeout>> listAll() {
|
||||
// 打开超时响应页时顺带纠偏旧运输类型文案、回填唯一编码(幂等)
|
||||
// 打开超时响应页时顺带纠偏旧运输类型文案、回填唯一编码、补协商再问种子(幂等)
|
||||
responseTimeoutService.ensureCanonicalTransportTypeLabels();
|
||||
responseTimeoutService.ensureTimeoutCodes();
|
||||
responseTimeoutService.ensureDealNegotiateRow();
|
||||
QueryWrapper<InquiryResponseTimeout> qw = new QueryWrapper<>();
|
||||
qw.orderByAsc("sort_no");
|
||||
return Result.OK(responseTimeoutService.list(qw));
|
||||
|
||||
+22
@@ -14,10 +14,12 @@ import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
|
||||
import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
||||
import org.apache.shiro.authz.annotation.Logical;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -66,6 +68,26 @@ public class InquiryWorkOrderController {
|
||||
return viewList(pageNo, pageSize, req);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前列表筛选导出 Excel。有 export 或 list 权限即可(避免只开了查询的角色点导出 403)。
|
||||
*/
|
||||
@AutoLog(value = "工单-导出Excel")
|
||||
@ApiOperation("按筛选导出工单 Excel")
|
||||
@RequiresPermissions(value = {"inquiry:workorder:export", "inquiry:workorder:list"}, logical = Logical.OR)
|
||||
@GetMapping("/view/export")
|
||||
public void viewExport(HttpServletRequest req, HttpServletResponse response) throws Exception {
|
||||
Map<String, String> filters = new HashMap<>();
|
||||
copyParam(req, filters, "keyword");
|
||||
copyParam(req, filters, "status");
|
||||
copyParam(req, filters, "bizType");
|
||||
copyParam(req, filters, "businessLine");
|
||||
copyParam(req, filters, "eventException");
|
||||
copyParam(req, filters, "systemException");
|
||||
copyParam(req, filters, "dateFrom");
|
||||
copyParam(req, filters, "dateTo");
|
||||
workOrderService.exportExcel(filters, response);
|
||||
}
|
||||
|
||||
@AutoLog(value = "工单-聚合详情")
|
||||
@ApiOperation("聚合详情")
|
||||
@RequiresPermissions("inquiry:workorder:list")
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package org.jeecg.modules.inquiry.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 强制清空协商再问时刻。
|
||||
* <p>
|
||||
* 背景:claimDueNegotiate 若用 updateById 写 null,MyBatis-Plus 会跳过,
|
||||
* Worker 每分钟重复认领并群发「确认成交状态」(WO202610060024)。
|
||||
* 本接口用 UpdateWrapper 强制 SET null,供智能体认领后补清。
|
||||
*/
|
||||
@Api(tags = "询价-协商再问清理")
|
||||
@RestController
|
||||
@RequestMapping("/inquiry/agent/ticket")
|
||||
public class NegotiateFollowupClearController {
|
||||
|
||||
@Resource
|
||||
private InquiryTicketMapper ticketMapper;
|
||||
|
||||
@ApiOperation("强制清空 next_followup_at")
|
||||
@PostMapping("/clearNextFollowup")
|
||||
public Result<Map<String, Object>> clearNextFollowup(
|
||||
@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
String no = first(body, "workOrderNo", "work_order_no");
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (StringUtils.isBlank(no)) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "missing_work_order_no");
|
||||
return Result.OK(out);
|
||||
}
|
||||
boolean ok = ticketMapper.update(
|
||||
null,
|
||||
new LambdaUpdateWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getWorkOrderNo, no.trim())
|
||||
.set(InquiryTicket::getNextFollowupAt, null)
|
||||
.set(InquiryTicket::getUpdateBy, "agent")
|
||||
.set(InquiryTicket::getUpdateTime, new Date())) > 0;
|
||||
out.put("ok", ok);
|
||||
out.put("work_order_no", no.trim());
|
||||
out.put("cleared", ok);
|
||||
return Result.OK(out);
|
||||
}
|
||||
|
||||
private static String first(Map<String, Object> body, String... keys) {
|
||||
if (body == null) {
|
||||
return "";
|
||||
}
|
||||
for (String key : keys) {
|
||||
Object val = body.get(key);
|
||||
if (val != null && StringUtils.isNotBlank(String.valueOf(val))) {
|
||||
return String.valueOf(val).trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
+1
@@ -20,6 +20,7 @@ import java.util.Date;
|
||||
* 维度行由种子固定(运输方式/角色/线路类别/运输类型),后台只允许改 timeoutHours。
|
||||
* timeoutCode 每条唯一,供智能体单码查询超时小时;roleCode 仅为角色维度,可重复。
|
||||
* 本表是配置真相;工单超时判定读 timeoutHours(允许小数,0=不监测)。
|
||||
* 成交跟进 deal_negotiate:协商中再问工作小时(0=不排再问),transportMode=DEAL。
|
||||
* </p>
|
||||
*/
|
||||
@Data
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@ public class InquiryTicket implements Serializable {
|
||||
private String lostReason;
|
||||
|
||||
/**
|
||||
* 协商中下次再问时刻(第四个工作日 09:00)。
|
||||
* 协商中下次再问时刻(工作小时顺延,小时数读 deal_negotiate 超时配置)。
|
||||
* 中途改成交/未成交或再问发出后清空。
|
||||
*/
|
||||
@ApiModelProperty("协商中下次再问时间")
|
||||
|
||||
+7
-3
@@ -9,9 +9,13 @@ public interface IInquiryDashboardService {
|
||||
|
||||
/**
|
||||
* 返回看板指标:metrics + trend + rankings + costDates/costRoutes。
|
||||
* rankings 与工单列表同套发起人解析;costRoutes 为近 7 日线路均价(主账工单投影)。
|
||||
* rankings 与工单列表同套发起人解析;costRoutes 为选定周期内线路均价(主账工单投影)。
|
||||
* <p>
|
||||
* 有 dateFrom/dateTo 时以显式日期为准(与工单列表筛选同源);否则回退 range=today|7d|30d。
|
||||
*
|
||||
* @param range today|7d|30d,默认 today
|
||||
* @param range today|7d|30d,默认 today(无显式日期时)
|
||||
* @param dateFrom 起日 yyyy-MM-dd,可空
|
||||
* @param dateTo 止日 yyyy-MM-dd,可空
|
||||
*/
|
||||
Map<String, Object> metrics(String range);
|
||||
Map<String, Object> metrics(String range, String dateFrom, String dateTo);
|
||||
}
|
||||
|
||||
+8
@@ -35,6 +35,14 @@ public interface IInquiryResponseTimeoutService extends IService<InquiryResponse
|
||||
*/
|
||||
void ensureTimeoutCodes();
|
||||
|
||||
/**
|
||||
* 确保「协商中再问成交」配置行存在(编码 deal_negotiate,默认 48 工作小时)。
|
||||
* <p>
|
||||
* 打开超时响应页时幂等插入;已有则不动小时数,避免覆盖运营改过的值。
|
||||
* </p>
|
||||
*/
|
||||
void ensureDealNegotiateRow();
|
||||
|
||||
/**
|
||||
* 按唯一业务编码查超时配置;无则 null。
|
||||
*/
|
||||
|
||||
+9
@@ -22,6 +22,15 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
*/
|
||||
IPage<InquiryTicket> pageView(Page<InquiryTicket> page, Map<String, String> filters);
|
||||
|
||||
/**
|
||||
* 按与 {@link #pageView} 相同的筛选条件导出 Excel(不分页,有行数上限)。
|
||||
* 副作用:只写 HTTP 响应流,不改工单六态。
|
||||
*
|
||||
* @param filters 与列表同源的筛选
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
void exportExcel(Map<String, String> filters, javax.servlet.http.HttpServletResponse response) throws java.io.IOException;
|
||||
|
||||
/**
|
||||
* 聚合详情:工单 + 报价列表 + 附件 + 时间线 + 开放异常。
|
||||
*/
|
||||
|
||||
+47
-27
@@ -78,19 +78,14 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
private InquiryQuoteMapper quoteMapper;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> metrics(String range) {
|
||||
LocalDate today = LocalDate.now(ZONE);
|
||||
LocalDate fromDate;
|
||||
if ("7d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(6);
|
||||
} else if ("30d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(29);
|
||||
} else {
|
||||
fromDate = today;
|
||||
}
|
||||
public Map<String, Object> metrics(String range, String dateFromStr, String dateToStr) {
|
||||
LocalDate[] bounds = resolveMetricBounds(range, dateFromStr, dateToStr);
|
||||
LocalDate fromDate = bounds[0];
|
||||
LocalDate toDate = bounds[1];
|
||||
Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant());
|
||||
Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(ZONE).toInstant());
|
||||
Date to = Date.from(LocalDateTime.of(toDate, LocalTime.MAX).atZone(ZONE).toInstant());
|
||||
|
||||
// 指标 / 排名 / 趋势 / 成本:同一批工单、同一日期窗,与工单列表 dateFrom~dateTo 对齐
|
||||
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, from)
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
@@ -127,28 +122,24 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
metricCards.add(card("avgResp", "内部报价平均响应",
|
||||
"AVG(首次有效回复时间 − 分发时间)", avgResp, true));
|
||||
|
||||
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
||||
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
|
||||
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, weekFrom)
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
InquiryDataScopeSupport.applySelfOnly(weekQw);
|
||||
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
|
||||
|
||||
List<String> trendDates = new ArrayList<>(7);
|
||||
List<Map<String, Object>> trend = new ArrayList<>(7);
|
||||
// 趋势 / 成本:按选定起止日逐日铺点(单日则一点;长周期最多 62 天防图爆炸)
|
||||
List<String> trendDates = new ArrayList<>();
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
Map<LocalDate, Integer> dayIndex = new HashMap<>();
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
LocalDate d = today.minusDays(i);
|
||||
dayIndex.put(d, trendDates.size());
|
||||
String label = d.format(DAY_FMT);
|
||||
LocalDate cursor = fromDate;
|
||||
int dayGuard = 0;
|
||||
while (!cursor.isAfter(toDate) && dayGuard < 62) {
|
||||
dayIndex.put(cursor, trendDates.size());
|
||||
String label = cursor.format(DAY_FMT);
|
||||
trendDates.add(label);
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("date", label);
|
||||
p.put("value", 0L);
|
||||
trend.add(p);
|
||||
cursor = cursor.plusDays(1);
|
||||
dayGuard++;
|
||||
}
|
||||
for (InquiryTicket t : weekTickets) {
|
||||
for (InquiryTicket t : tickets) {
|
||||
LocalDate d = toLocalDate(t.getCreateTime());
|
||||
Integer idx = d == null ? null : dayIndex.get(d);
|
||||
if (idx == null) {
|
||||
@@ -174,10 +165,12 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rankings = buildRankings(tickets, staffByWecom, staffByName);
|
||||
List<Map<String, Object>> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates);
|
||||
List<Map<String, Object>> costRoutes = buildCostRoutes(tickets, dayIndex, trendDates);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("range", range == null ? "today" : range);
|
||||
result.put("dateFrom", fromDate.toString());
|
||||
result.put("dateTo", toDate.toString());
|
||||
result.put("metrics", metricCards);
|
||||
result.put("trend", trend);
|
||||
result.put("rankings", rankings);
|
||||
@@ -186,6 +179,33 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析看板统计起止日。
|
||||
* 优先用显式 dateFrom/dateTo(与工单列表同源);否则按 range=today|7d|30d 相对「今天」。
|
||||
*/
|
||||
private static LocalDate[] resolveMetricBounds(String range, String dateFromStr, String dateToStr) {
|
||||
if (StringUtils.isNotBlank(dateFromStr) && StringUtils.isNotBlank(dateToStr)) {
|
||||
LocalDate fromDate = LocalDate.parse(dateFromStr.trim());
|
||||
LocalDate toDate = LocalDate.parse(dateToStr.trim());
|
||||
if (toDate.isBefore(fromDate)) {
|
||||
LocalDate tmp = fromDate;
|
||||
fromDate = toDate;
|
||||
toDate = tmp;
|
||||
}
|
||||
return new LocalDate[]{fromDate, toDate};
|
||||
}
|
||||
LocalDate today = LocalDate.now(ZONE);
|
||||
LocalDate fromDate;
|
||||
if ("7d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(6);
|
||||
} else if ("30d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(29);
|
||||
} else {
|
||||
fromDate = today;
|
||||
}
|
||||
return new LocalDate[]{fromDate, today};
|
||||
}
|
||||
|
||||
/**
|
||||
* 人员排名:与工单列表 enrichDisplayFields 同口径。
|
||||
* 聚合键优先 salesWecomId(同人合并),展示名走 resolveSalesDisplayName。
|
||||
|
||||
+8
@@ -1176,6 +1176,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
if (!routeRows.isEmpty()) {
|
||||
QuoteTemplateExcelSupport.copyMissingWeightPrices(landSegs, rawLand);
|
||||
}
|
||||
// 销售已选线路透传:跨境整车国内/境外段按 legs 写完整起讫(多备选混在 route_rows 时不能靠同序列过滤)
|
||||
Object selectedLand = q.get("selected_land_route");
|
||||
if (selectedLand == null) {
|
||||
selectedLand = q.get("selectedLandRoute");
|
||||
}
|
||||
if (selectedLand != null) {
|
||||
out.put("selected_land_route", selectedLand);
|
||||
}
|
||||
if (!landSegs.isEmpty()) {
|
||||
out.put("landSegments", landSegs);
|
||||
out.put("route_rows", landSegs);
|
||||
|
||||
+47
@@ -8,6 +8,7 @@ import org.jeecg.modules.inquiry.service.IInquiryResponseTimeoutService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -64,11 +65,19 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000008", "land_hk_cross");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000009", "land_hk_special");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000010", "land_hk_express");
|
||||
// 成交跟进:协商中再问(跨业务线,transport_mode=DEAL)
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000030", "deal_negotiate");
|
||||
}
|
||||
|
||||
/** 协商中再问成交:唯一编码,智能体 finish_deal 读此码取工作小时。 */
|
||||
public static final String CODE_DEAL_NEGOTIATE = "deal_negotiate";
|
||||
|
||||
private static final String ID_DEAL_NEGOTIATE = "rt100000000000000000000000000030";
|
||||
|
||||
/** 本进程内只纠偏一次,避免每次打开系统设置都打 UPDATE */
|
||||
private volatile boolean labelsEnsured;
|
||||
private volatile boolean codesEnsured;
|
||||
private volatile boolean dealNegotiateEnsured;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -152,6 +161,44 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void ensureDealNegotiateRow() {
|
||||
if (dealNegotiateEnsured) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (dealNegotiateEnsured) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
InquiryResponseTimeout existed = getByTimeoutCode(CODE_DEAL_NEGOTIATE);
|
||||
if (existed != null) {
|
||||
dealNegotiateEnsured = true;
|
||||
return;
|
||||
}
|
||||
// 缺行则种子插入;小时默认 48,之后只许运营在后台改小时
|
||||
InquiryResponseTimeout row = new InquiryResponseTimeout();
|
||||
row.setId(ID_DEAL_NEGOTIATE);
|
||||
row.setTimeoutCode(CODE_DEAL_NEGOTIATE);
|
||||
row.setTransportMode("DEAL");
|
||||
row.setRoleCode("sales");
|
||||
row.setRouteCategory("");
|
||||
row.setTransportType("协商中再问成交");
|
||||
row.setTimeoutHours(new BigDecimal("48"));
|
||||
row.setSortNo(5);
|
||||
row.setCreateBy("system");
|
||||
row.setCreateTime(new Date());
|
||||
row.setUpdateBy("system");
|
||||
row.setUpdateTime(new Date());
|
||||
save(row);
|
||||
dealNegotiateEnsured = true;
|
||||
} catch (Exception e) {
|
||||
log.warn("ensureDealNegotiateRow 跳过: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InquiryResponseTimeout getByTimeoutCode(String timeoutCode) {
|
||||
if (StringUtils.isBlank(timeoutCode)) {
|
||||
|
||||
+57
-5
@@ -32,6 +32,7 @@ import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketFactsSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketPriceSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryWorkOrderExcelExport;
|
||||
import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -39,6 +40,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
@@ -82,6 +85,28 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
|
||||
@Override
|
||||
public IPage<InquiryTicket> pageView(Page<InquiryTicket> page, Map<String, String> filters) {
|
||||
LambdaQueryWrapper<InquiryTicket> qw = buildListQuery(filters);
|
||||
IPage<InquiryTicket> result = page(page, qw);
|
||||
enrichDisplayFields(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exportExcel(Map<String, String> filters, HttpServletResponse response) throws IOException {
|
||||
// 与列表同筛选;分页上限防止一次导出拖垮内存
|
||||
Page<InquiryTicket> page = new Page<>(1, InquiryWorkOrderExcelExport.MAX_ROWS);
|
||||
IPage<InquiryTicket> result = page(page, buildListQuery(filters));
|
||||
enrichDisplayFields(result.getRecords());
|
||||
InquiryWorkOrderExcelExport.write(result.getRecords(), response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表 / 导出共用筛选:关键字、六态、业务类型、异常、创建日、数据范围。
|
||||
*/
|
||||
private LambdaQueryWrapper<InquiryTicket> buildListQuery(Map<String, String> filters) {
|
||||
if (filters == null) {
|
||||
filters = Collections.emptyMap();
|
||||
}
|
||||
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<>();
|
||||
String keyword = filters.get("keyword");
|
||||
if (StringUtils.isNotBlank(keyword)) {
|
||||
@@ -126,9 +151,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
// 与看板同源:账号勾「只看自己」时收紧列表(非超管)
|
||||
InquiryDataScopeSupport.applySelfOnly(qw);
|
||||
qw.orderByDesc(InquiryTicket::getCreateTime);
|
||||
IPage<InquiryTicket> result = page(page, qw);
|
||||
enrichDisplayFields(result.getRecords());
|
||||
return result;
|
||||
return qw;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1520,7 +1543,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
|
||||
/**
|
||||
* 认领到期协商再问:状态仍是协商中且 next_followup_at 已到。
|
||||
* 清空再问时刻,Worker 只发这一轮;销售再选协商中才会排下一轮。
|
||||
* <p>
|
||||
* 必须用 UpdateWrapper 强制把 next_followup_at 写成 null:MyBatis-Plus updateById
|
||||
* 默认跳过 null,库里时刻清不掉,Worker 每分钟会再认领并群发「确认成交状态」
|
||||
* (WO202610060024 同一话术连发多条)。多 Worker 时按行条件更新,更新失败则本行不返回。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -1536,12 +1562,38 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return out;
|
||||
}
|
||||
for (InquiryTicket ticket : due) {
|
||||
// 条件更新:仅当再问时刻仍未清空时认领成功,避免双 Worker 各发一轮
|
||||
boolean claimed = lambdaUpdate()
|
||||
.eq(InquiryTicket::getId, ticket.getId())
|
||||
.eq(InquiryTicket::getStatus, TicketStatuses.NEGOTIATING)
|
||||
.isNotNull(InquiryTicket::getNextFollowupAt)
|
||||
.le(InquiryTicket::getNextFollowupAt, now)
|
||||
.set(InquiryTicket::getNextFollowupAt, null)
|
||||
.set(InquiryTicket::getUpdateBy, "agent")
|
||||
.set(InquiryTicket::getUpdateTime, now)
|
||||
.update();
|
||||
if (!claimed) {
|
||||
continue;
|
||||
}
|
||||
ticket.setNextFollowupAt(null);
|
||||
ticket.setUpdateBy("agent");
|
||||
ticket.setUpdateTime(now);
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "negotiate_followup", "agent", "协商再问已发出");
|
||||
out.add(toDueNegotiateRow(ticket));
|
||||
// #region agent log
|
||||
try {
|
||||
java.nio.file.Path dbg = java.nio.file.Paths.get(
|
||||
"d:/own/友通达询价智能体(新)/debug-44a132.log");
|
||||
String line = "{\"sessionId\":\"44a132\",\"runId\":\"post-fix\",\"hypothesisId\":\"H-dup\","
|
||||
+ "\"location\":\"claimDueNegotiate\",\"message\":\"claimed_clear_followup\","
|
||||
+ "\"data\":{\"workOrderNo\":\"" + ticket.getWorkOrderNo() + "\"},"
|
||||
+ "\"timestamp\":" + System.currentTimeMillis() + "}\n";
|
||||
java.nio.file.Files.write(dbg, line.getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
|
||||
} catch (Exception ignored) {
|
||||
// 调试日志失败不挡认领
|
||||
}
|
||||
// #endregion
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 询价工单列表 → Excel(xlsx)。
|
||||
* <p>
|
||||
* 职责:按列表同口径列写出文件流;不查库、不改六态。
|
||||
* 线程:仅在导出请求线程写响应,禁止塞进回调/Worker。
|
||||
* 为何:后台「导出工单」要按当前筛选落 Excel,与列表筛选同源。
|
||||
*/
|
||||
public final class InquiryWorkOrderExcelExport {
|
||||
|
||||
private static final String[] HEADERS = {
|
||||
"工单编号", "发起人", "业务类型", "询价需求", "起运地", "目的地", "客户",
|
||||
"报价信息", "单据状态", "成本价", "销售价", "利润点",
|
||||
"事件异常", "系统异常", "系统异常原因", "创建时间"
|
||||
};
|
||||
|
||||
/** 单次导出上限,防止一次拖垮 Tomcat 内存;超过则只写前 N 条。 */
|
||||
public static final int MAX_ROWS = 20000;
|
||||
|
||||
private InquiryWorkOrderExcelExport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 写出 xlsx 到 HTTP 响应。
|
||||
*
|
||||
* @param tickets 已 enrich 展示字段的工单
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
public static void write(List<InquiryTicket> tickets, HttpServletResponse response) throws IOException {
|
||||
String fileName = "询价工单_" + new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()) + ".xlsx";
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.setHeader("Content-Disposition",
|
||||
"attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()));
|
||||
|
||||
try (SXSSFWorkbook wb = new SXSSFWorkbook(200)) {
|
||||
Sheet sheet = wb.createSheet("询价工单");
|
||||
Row head = sheet.createRow(0);
|
||||
for (int i = 0; i < HEADERS.length; i++) {
|
||||
Cell c = head.createCell(i);
|
||||
c.setCellValue(HEADERS[i]);
|
||||
}
|
||||
SimpleDateFormat dt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
int limit = Math.min(tickets == null ? 0 : tickets.size(), MAX_ROWS);
|
||||
for (int i = 0; i < limit; i++) {
|
||||
InquiryTicket t = tickets.get(i);
|
||||
Row row = sheet.createRow(i + 1);
|
||||
writeCell(row, 0, t.getWorkOrderNo());
|
||||
writeCell(row, 1, firstNonBlank(t.getSalesName(), t.getSalesWecomId()));
|
||||
writeCell(row, 2, firstNonBlank(t.getBizTypeLabel(), t.getBusinessLine()));
|
||||
writeCell(row, 3, firstNonBlank(t.getDemandTitle(), t.getDemandDetail()));
|
||||
writeCell(row, 4, t.getOrigin());
|
||||
writeCell(row, 5, t.getDestination());
|
||||
writeCell(row, 6, t.getCustomerName());
|
||||
writeCell(row, 7, quoteSummary(t));
|
||||
writeCell(row, 8, t.getStatus());
|
||||
writeCell(row, 9, t.getCostPrice());
|
||||
writeCell(row, 10, t.getSalePrice());
|
||||
writeCell(row, 11, t.getProfitRate());
|
||||
writeCell(row, 12, dash(t.getEventException()));
|
||||
writeCell(row, 13, dash(t.getSystemException()));
|
||||
writeCell(row, 14, dash(t.getSystemExceptionReason()));
|
||||
writeCell(row, 15, t.getCreateTime() == null ? "" : dt.format(t.getCreateTime()));
|
||||
}
|
||||
wb.write(response.getOutputStream());
|
||||
response.flushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
private static String quoteSummary(InquiryTicket t) {
|
||||
List<InquiryQuote> quotes = t.getQuotes();
|
||||
if (quotes == null || quotes.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return quotes.stream()
|
||||
.filter(q -> q != null)
|
||||
.map(q -> {
|
||||
String src = StringUtils.defaultIfBlank(q.getSourceLabel(), q.getSource());
|
||||
String price = StringUtils.defaultString(q.getPriceText());
|
||||
if (StringUtils.isBlank(src) && StringUtils.isBlank(price)) {
|
||||
return "";
|
||||
}
|
||||
return (StringUtils.defaultString(src) + " " + price).trim();
|
||||
})
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.joining(";"));
|
||||
}
|
||||
|
||||
private static void writeCell(Row row, int idx, String value) {
|
||||
Cell c = row.createCell(idx);
|
||||
c.setCellValue(value == null ? "" : value);
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
if (StringUtils.isNotBlank(a)) {
|
||||
return a.trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(b)) {
|
||||
return b.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String dash(String v) {
|
||||
if (StringUtils.isBlank(v) || "-".equals(v.trim())) {
|
||||
return "-";
|
||||
}
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
+311
-18
@@ -18,7 +18,6 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
@@ -2798,7 +2797,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
int mappedStart = rowIdx0;
|
||||
rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow"));
|
||||
String kind = landRouteSectionKind(sheet, region);
|
||||
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size());
|
||||
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size(), facts);
|
||||
if (sectionRoutes == null || sectionRoutes.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
@@ -2858,7 +2857,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isBlank(col)) {
|
||||
continue;
|
||||
}
|
||||
String price = resolveRouteVehicleRate(rowObj, facts, vk, vehicleRates.size());
|
||||
String price = resolveRouteVehicleRate(rowObj, facts, vk, vehicleRates.size(), kind);
|
||||
if (StringUtils.isNotBlank(price) && !"-".equals(price)) {
|
||||
writeFeeCol(sheet, ri, col, price, yellow, sheet.getSheetName());
|
||||
}
|
||||
@@ -2937,6 +2936,19 @@ public final class QuoteTemplateExcelSupport {
|
||||
return firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"),
|
||||
rowObj.getString("costPrice"));
|
||||
}
|
||||
// 集拼矩阵副表头「最低收费」:有分档单价时只用 min_charge;分档壳无单价时回退整票价。
|
||||
if (header.contains("最低收费")) {
|
||||
String minCharge = firstNonBlank(rowObj.getString("min_charge"), rowObj.getString("minCharge"));
|
||||
if (StringUtils.isNotBlank(minCharge) && !"-".equals(minCharge)) {
|
||||
return formatTierPrice(minCharge);
|
||||
}
|
||||
JSONArray tiers = weightPricesOf(rowObj);
|
||||
if (tiers != null && !tiers.isEmpty() && !anyWeightTierHasPrice(tiers)) {
|
||||
return formatTierPrice(firstNonBlank(rowObj.getString("salePrice"),
|
||||
rowObj.getString("costPrice"), rowObj.getString("amount")));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -3092,11 +3104,19 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内段写境内城市,境外段写境外城市。两段 TMS 腿按腿序号对上。
|
||||
* 只有一对郑州→吉隆坡时:国内段只写郑州,境外段只写吉隆坡,不发明口岸。
|
||||
* 国内段 / 境外段各写对应 TMS 腿的完整起讫。
|
||||
* <p>
|
||||
* 优先用 selected_land_route.legs(销售已选线路);否则从多条备选里挑一条多腿线路。
|
||||
* 只有一对「境内→境外」且无两段腿时:国内段只写境内点、境外段只写境外点,不发明口岸。
|
||||
* WO202610120001:备选混在 route_rows 时若要求整表同 segmentSequence,会退回切片导致口岸空。
|
||||
*/
|
||||
static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region,
|
||||
int regionIndex, int regionCount) {
|
||||
return routesForLandRouteSection(routes, sheet, region, regionIndex, regionCount, null);
|
||||
}
|
||||
|
||||
static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region,
|
||||
int regionIndex, int regionCount, JSONObject facts) {
|
||||
if (routes == null || routes.isEmpty()) {
|
||||
return routes;
|
||||
}
|
||||
@@ -3107,12 +3127,15 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isBlank(kind)) {
|
||||
return routes;
|
||||
}
|
||||
JSONArray byLeg = pickLandRouteLegsForSection(routes, kind);
|
||||
JSONArray preferred = preferredLandRouteLegs(facts, routes);
|
||||
JSONArray byLeg = pickLandRouteLegsForSection(preferred, kind);
|
||||
if (byLeg != null && !byLeg.isEmpty()) {
|
||||
return byLeg;
|
||||
}
|
||||
JSONObject seed = preferred != null && !preferred.isEmpty()
|
||||
? preferred.getJSONObject(0) : routes.getJSONObject(0);
|
||||
JSONArray sliced = new JSONArray();
|
||||
JSONObject one = sliceCrossBorderOd(routes.getJSONObject(0), kind);
|
||||
JSONObject one = sliceCrossBorderOd(seed, kind);
|
||||
if (one != null && (StringUtils.isNotBlank(one.getString("origin"))
|
||||
|| StringUtils.isNotBlank(one.getString("destination")))) {
|
||||
sliced.add(one);
|
||||
@@ -3120,16 +3143,91 @@ public final class QuoteTemplateExcelSupport {
|
||||
return sliced;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出单优先腿:已选线路 legs > 同 segmentSequence 且腿数最多的备选 > 原数组。
|
||||
*/
|
||||
static JSONArray preferredLandRouteLegs(JSONObject facts, JSONArray allRoutes) {
|
||||
if (facts != null) {
|
||||
Object rawSel = facts.get("selected_land_route");
|
||||
if (rawSel == null) {
|
||||
rawSel = facts.get("selectedLandRoute");
|
||||
}
|
||||
JSONObject sel = null;
|
||||
if (rawSel instanceof JSONObject) {
|
||||
sel = (JSONObject) rawSel;
|
||||
} else if (rawSel instanceof String && StringUtils.isNotBlank((String) rawSel)) {
|
||||
try {
|
||||
Object parsed = JSON.parse((String) rawSel);
|
||||
if (parsed instanceof JSONObject) {
|
||||
sel = (JSONObject) parsed;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 忽略坏 JSON
|
||||
}
|
||||
} else if (rawSel instanceof java.util.Map) {
|
||||
sel = JSON.parseObject(JSON.toJSONString(rawSel));
|
||||
}
|
||||
if (sel != null) {
|
||||
JSONArray legs = parseFeeRows(sel.get("legs"));
|
||||
if (legs != null && !legs.isEmpty()) {
|
||||
return legs;
|
||||
}
|
||||
}
|
||||
}
|
||||
JSONArray grouped = pickPrimaryLandRouteGroup(allRoutes);
|
||||
return grouped != null && !grouped.isEmpty() ? grouped : allRoutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多条备选(不同 segmentSequence)时,挑腿数最多的一条完整线路;并列取较小序号。
|
||||
*/
|
||||
static JSONArray pickPrimaryLandRouteGroup(JSONArray routes) {
|
||||
if (routes == null || routes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
java.util.Map<String, JSONArray> groups = new java.util.LinkedHashMap<>();
|
||||
for (int i = 0; i < routes.size(); i++) {
|
||||
JSONObject row = routes.getJSONObject(i);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String seq = StringUtils.trimToEmpty(row.getString("segmentSequence"));
|
||||
if (seq.isEmpty()) {
|
||||
seq = "1";
|
||||
}
|
||||
groups.computeIfAbsent(seq, k -> new JSONArray()).add(row);
|
||||
}
|
||||
if (groups.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String bestKey = null;
|
||||
int bestLegs = -1;
|
||||
int bestSeqNum = Integer.MAX_VALUE;
|
||||
for (java.util.Map.Entry<String, JSONArray> e : groups.entrySet()) {
|
||||
int legs = e.getValue() == null ? 0 : e.getValue().size();
|
||||
int seqNum;
|
||||
try {
|
||||
seqNum = Integer.parseInt(e.getKey().trim());
|
||||
} catch (RuntimeException ex) {
|
||||
seqNum = Integer.MAX_VALUE;
|
||||
}
|
||||
if (legs > bestLegs || (legs == bestLegs && seqNum < bestSeqNum)) {
|
||||
bestLegs = legs;
|
||||
bestSeqNum = seqNum;
|
||||
bestKey = e.getKey();
|
||||
}
|
||||
}
|
||||
return bestKey == null ? null : groups.get(bestKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一条线路的多段腿:国内段取首腿完整 OD,境外段取末腿完整 OD。
|
||||
* 不足两腿时返回 null,交给 sliceCrossBorderOd。
|
||||
*/
|
||||
static JSONArray pickLandRouteLegsForSection(JSONArray routes, String kind) {
|
||||
if (routes == null || routes.size() < 2) {
|
||||
return null;
|
||||
}
|
||||
String seq = StringUtils.trimToEmpty(routes.getJSONObject(0).getString("segmentSequence"));
|
||||
for (int i = 1; i < routes.size(); i++) {
|
||||
if (!seq.equals(StringUtils.trimToEmpty(routes.getJSONObject(i).getString("segmentSequence")))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
JSONArray ordered = new JSONArray();
|
||||
ordered.addAll(routes);
|
||||
ordered.sort((a, b) -> {
|
||||
@@ -3140,8 +3238,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
JSONArray out = new JSONArray();
|
||||
if ("domestic".equals(kind)) {
|
||||
out.add(ordered.getJSONObject(0));
|
||||
} else {
|
||||
} else if ("overseas".equals(kind)) {
|
||||
out.add(ordered.getJSONObject(ordered.size() - 1));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -3483,6 +3583,14 @@ public final class QuoteTemplateExcelSupport {
|
||||
* 禁止「无车型时把同一售价灌进所有费种列」。
|
||||
*/
|
||||
static String resolveRouteVehicleRate(JSONObject rowObj, JSONObject facts, String headerKey, int headerCount) {
|
||||
return resolveRouteVehicleRate(rowObj, facts, headerKey, headerCount, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param sectionKind domestic / overseas / 空;境外段要把国内标注 3T 对成 3.5T 再写价。
|
||||
*/
|
||||
static String resolveRouteVehicleRate(JSONObject rowObj, JSONObject facts, String headerKey,
|
||||
int headerCount, String sectionKind) {
|
||||
if (StringUtils.isBlank(headerKey)) {
|
||||
return "";
|
||||
}
|
||||
@@ -3517,7 +3625,20 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
// 跨境集拼是分档公斤价(2-500KG=12 …),不是把同一个 price_per_kg 灌进每一列。
|
||||
if (looksLikeKgBracketHeader(key)) {
|
||||
return matchWeightBracketPrice(rowObj, key);
|
||||
String band = matchWeightBracketPrice(rowObj, key);
|
||||
if (StringUtils.isNotBlank(band)) {
|
||||
return band;
|
||||
}
|
||||
// TMS 偶发只给分档壳(min/max)+ 整票 costPrice(计价单位:次),无档内单价。
|
||||
// 禁止灌进所有公斤列;只写入询价重量落入的那一档,避免报价单全空。
|
||||
return flatLandFeeForEmptyWeightBands(rowObj, facts, key);
|
||||
}
|
||||
if (key.contains("最低收费")) {
|
||||
return formatTierPrice(firstNonBlank(rowObj == null ? null : rowObj.getString("min_charge"),
|
||||
rowObj == null ? null : rowObj.getString("minCharge"),
|
||||
rowObj == null ? null : rowObj.getString("salePrice"),
|
||||
rowObj == null ? null : rowObj.getString("costPrice"),
|
||||
rowObj == null ? null : rowObj.getString("amount")));
|
||||
}
|
||||
if (key.toUpperCase(Locale.ROOT).contains("KG") || key.contains("公斤")) {
|
||||
return firstNonBlank(rowObj == null ? null : rowObj.getString("price_per_kg"),
|
||||
@@ -3527,16 +3648,17 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (key.contains("运输") || key.contains("运费") || key.contains("陆运")) {
|
||||
return firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"),
|
||||
rowObj == null ? null : rowObj.getString("amount"),
|
||||
rowObj == null ? null : rowObj.getString("costPrice"),
|
||||
rowObj == null ? null : rowObj.getString("land_freight"),
|
||||
facts == null ? null : facts.getString("land_freight"));
|
||||
}
|
||||
// 2) 车型列:仅当 vehicleType 对得上才写售价
|
||||
// 2) 车型列:仅当 vehicleType 对得上才写售价(含跨境国内/国外标注换算)
|
||||
String vehicleType = firstNonBlank(rowObj == null ? null : rowObj.getString("vehicleType"),
|
||||
rowObj == null ? null : rowObj.getString("vehicle_type"));
|
||||
String price = firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"),
|
||||
rowObj == null ? null : rowObj.getString("costPrice"),
|
||||
rowObj == null ? null : rowObj.getString("amount"));
|
||||
if (StringUtils.isNotBlank(vehicleType) && vehicleTypeEquals(vehicleType, key)) {
|
||||
if (StringUtils.isNotBlank(vehicleType) && vehicleTypeMatchesSection(vehicleType, key, sectionKind)) {
|
||||
return price;
|
||||
}
|
||||
// 3) 仅一列且无车型信息时,允许写入该列(整车单列模板)
|
||||
@@ -3546,6 +3668,65 @@ public final class QuoteTemplateExcelSupport {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 车型列是否对得上当前段表头。
|
||||
* <p>
|
||||
* 跨境整车:询价/TMS 常写国内标注「3T」,国外段表头是「3.5T」;
|
||||
* 境外段用国内→国外映射后再比,国内段反之。禁止把 3T 误当成所有 T 列。
|
||||
*/
|
||||
static boolean vehicleTypeMatchesSection(String vehicleType, String headerKey, String sectionKind) {
|
||||
if (vehicleTypeEquals(vehicleType, headerKey)) {
|
||||
return true;
|
||||
}
|
||||
String kind = StringUtils.trimToEmpty(sectionKind);
|
||||
if ("overseas".equals(kind)) {
|
||||
String mapped = mapDomesticVehicleToOverseas(vehicleType);
|
||||
return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey);
|
||||
}
|
||||
if ("domestic".equals(kind)) {
|
||||
String mapped = mapOverseasVehicleToDomestic(vehicleType);
|
||||
return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内整车标注 → 跨境国外段表头。WO202610080006:3T 对 3.5T。
|
||||
* 未收录的返回空,禁止瞎猜。
|
||||
*/
|
||||
static String mapDomesticVehicleToOverseas(String vehicleType) {
|
||||
String a = normalizeVehicleToken(vehicleType);
|
||||
if (a.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if ("3T".equals(a) || "3吨".equals(a)) {
|
||||
return "3.5T";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 国外段标注 → 国内段表头(单腿 TMS 偶发直接回 3.5T 时,国内段仍能写进 3T)。
|
||||
*/
|
||||
static String mapOverseasVehicleToDomestic(String vehicleType) {
|
||||
String a = normalizeVehicleToken(vehicleType);
|
||||
if (a.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if ("3.5T".equals(a) || "3.5吨".equals(a)) {
|
||||
return "3T";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String normalizeVehicleToken(String vehicleType) {
|
||||
if (StringUtils.isBlank(vehicleType)) {
|
||||
return "";
|
||||
}
|
||||
return vehicleType.replace(" ", "").toUpperCase(Locale.ROOT)
|
||||
.replace("T", "T");
|
||||
}
|
||||
|
||||
/**
|
||||
* 表头是「2-500KG」「5001KG以上」这类分档,不是「元/KG」单价列。
|
||||
*/
|
||||
@@ -3555,6 +3736,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
|
||||
/**
|
||||
* 用 landSegments.weightPrices 对上分档表头。对不上就空着,禁止把一档价抄进所有列。
|
||||
* <p>
|
||||
* 档内单价优先读 price,兼容 TMS 偶发的 unitPrice/salePrice/costPrice/amount。
|
||||
*/
|
||||
static String matchWeightBracketPrice(JSONObject rowObj, String headerKey) {
|
||||
JSONArray tiers = weightPricesOf(rowObj);
|
||||
@@ -3571,12 +3754,122 @@ public final class QuoteTemplateExcelSupport {
|
||||
boolean open = tier.get("max") == null || StringUtils.isBlank(String.valueOf(tier.get("max")));
|
||||
int max = open ? -1 : intOf(tier.get("max"));
|
||||
if (min == range[0] && max == range[1]) {
|
||||
return formatTierPrice(tier.get("price"));
|
||||
return formatTierPrice(tierPriceRaw(tier));
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 分档壳有区间但没有任何档内单价时:把整票 salePrice/costPrice 写入询价重量所在那一档。
|
||||
* <p>
|
||||
* 为何:WO202610080043 跨境集拼 TMS 回 weightPrices 只有 min/max,费用是「次」18200;
|
||||
* 若所有公斤列都空着,销售会以为报价没写入。有真实档内单价时禁止走这条。
|
||||
* 副作用:只读 row/facts,不改库。
|
||||
*/
|
||||
static String flatLandFeeForEmptyWeightBands(JSONObject rowObj, JSONObject facts, String headerKey) {
|
||||
int[] range = parseKgBracket(headerKey);
|
||||
if (range == null || rowObj == null) {
|
||||
return "";
|
||||
}
|
||||
JSONArray tiers = weightPricesOf(rowObj);
|
||||
if (tiers == null || tiers.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (anyWeightTierHasPrice(tiers)) {
|
||||
return "";
|
||||
}
|
||||
String flat = firstNonBlank(rowObj.getString("salePrice"), rowObj.getString("costPrice"),
|
||||
rowObj.getString("amount"));
|
||||
if (StringUtils.isBlank(flat) || "-".equals(flat)) {
|
||||
return "";
|
||||
}
|
||||
double weight = inquiryWeightKg(rowObj, facts);
|
||||
boolean hit;
|
||||
if (weight >= 0) {
|
||||
hit = weightInKgBracket(weight, range[0], range[1]);
|
||||
} else {
|
||||
// 无询价重量:只写入 weightPrices 里的第一档,避免抄进所有列
|
||||
JSONObject first = tiers.getJSONObject(0);
|
||||
int min = first == null ? -1 : intOf(first.get("min"));
|
||||
boolean open = first == null || first.get("max") == null
|
||||
|| StringUtils.isBlank(String.valueOf(first.get("max")));
|
||||
int max = open ? -1 : intOf(first.get("max"));
|
||||
hit = min == range[0] && max == range[1];
|
||||
}
|
||||
return hit ? formatTierPrice(flat) : "";
|
||||
}
|
||||
|
||||
/** 档内是否已有任何可用单价(有则禁止整票价回填)。 */
|
||||
static boolean anyWeightTierHasPrice(JSONArray tiers) {
|
||||
if (tiers == null) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < tiers.size(); i++) {
|
||||
JSONObject tier = tiers.getJSONObject(i);
|
||||
if (tier != null && StringUtils.isNotBlank(formatTierPrice(tierPriceRaw(tier)))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** TMS 档内单价字段名不统一,按常见键取第一个非空。 */
|
||||
static Object tierPriceRaw(JSONObject tier) {
|
||||
if (tier == null) {
|
||||
return null;
|
||||
}
|
||||
Object raw = tier.get("price");
|
||||
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
|
||||
raw = tier.get("unitPrice");
|
||||
}
|
||||
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
|
||||
raw = tier.get("salePrice");
|
||||
}
|
||||
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
|
||||
raw = tier.get("costPrice");
|
||||
}
|
||||
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
|
||||
raw = tier.get("amount");
|
||||
}
|
||||
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
|
||||
raw = tier.get("pricePerKg");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** 询价毛重(KG);没有或解析失败返回 -1。 */
|
||||
static double inquiryWeightKg(JSONObject rowObj, JSONObject facts) {
|
||||
String raw = firstNonBlank(
|
||||
rowObj == null ? null : rowObj.getString("weight_kg"),
|
||||
rowObj == null ? null : rowObj.getString("weightKg"),
|
||||
facts == null ? null : facts.getString("weight_kg"),
|
||||
facts == null ? null : facts.getString("weightKg"),
|
||||
facts == null ? null : facts.getString("毛重"),
|
||||
facts == null ? null : facts.getString("重量"),
|
||||
facts == null ? null : facts.getString("weight"));
|
||||
if (StringUtils.isBlank(raw) || "-".equals(raw)) {
|
||||
return -1;
|
||||
}
|
||||
String num = raw.trim().replace(",", "").replaceAll("[Kk][Gg].*$", "").trim();
|
||||
try {
|
||||
return new java.math.BigDecimal(num).doubleValue();
|
||||
} catch (RuntimeException ex) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 重量是否落在 [min, max];max=-1 表示无上限。 */
|
||||
static boolean weightInKgBracket(double weight, int min, int max) {
|
||||
if (weight < 0 || min < 0) {
|
||||
return false;
|
||||
}
|
||||
if (weight < min) {
|
||||
return false;
|
||||
}
|
||||
return max < 0 || weight <= max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 route_rows 会丢掉 weightPrices。出单前从原始 landSegments 补回。
|
||||
*/
|
||||
|
||||
+33
-9
@@ -121,14 +121,15 @@ public class InquiryTmsQueryService {
|
||||
segment.put("cargoReadyDate", first(facts.get("cargoReadyDate"), facts.get("货好时间")));
|
||||
segment.put("placeOfReceipt", first(facts.get("placeOfReceipt"), facts.get("提货地址")));
|
||||
segment.put("deliveryAddress", first(facts.get("deliveryAddress"), facts.get("收货地址")));
|
||||
// 工单 added_services 在这里收成列表,后面的 mapper 原样放到 TMS addedServices。
|
||||
List<String> addedServices = TmsV2RequestMapper.splitAddedServices(first(
|
||||
facts.get("addedServices"), facts.get("added_services"), facts.get("增值服务")));
|
||||
if (!addedServices.isEmpty()) {
|
||||
segment.put("addedServices", addedServices);
|
||||
}
|
||||
}
|
||||
|
||||
String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(facts.get("报价日期"));
|
||||
if (quoteDate == null) {
|
||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");
|
||||
fmt.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
|
||||
quoteDate = fmt.format(new Date());
|
||||
}
|
||||
String quoteDate = resolveOutboundQuoteDate(line, facts.get("报价日期"));
|
||||
|
||||
List<String> missing;
|
||||
if ("SEA".equals(line)) {
|
||||
@@ -162,6 +163,10 @@ public class InquiryTmsQueryService {
|
||||
|
||||
String cacheKey = line + ":" + quoteDate + ":" + origin + ":" + destination
|
||||
+ ":" + segment.get("weightKg") + ":" + segment.get("volumeCbm");
|
||||
if ("AIR".equals(line) && segment.get("addedServices") != null) {
|
||||
// 增值服务不同不能共用同一条运价缓存。
|
||||
cacheKey += ":" + segment.get("addedServices");
|
||||
}
|
||||
if ("LAND".equals(line)) {
|
||||
cacheKey += ":" + segment.get("landTransportType") + ":" + segment.get("routeCategory")
|
||||
+ ":" + segment.get("vehicleType") + ":" + segment.get("quantity");
|
||||
@@ -1324,17 +1329,36 @@ public class InquiryTmsQueryService {
|
||||
return raw == null || String.valueOf(raw).trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查价出站用的报价日期。
|
||||
*
|
||||
* 海运、陆运没说则填上海当天。空运没说保持 null,不传 quoteDate,
|
||||
* 让 TMS 一次返回各航班日期的舱位。销售说了的日期原样规范化,不用当天盖掉。
|
||||
*/
|
||||
static String resolveOutboundQuoteDate(String line, Object raw) {
|
||||
String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(raw);
|
||||
if (quoteDate != null) {
|
||||
return quoteDate;
|
||||
}
|
||||
if ("AIR".equals(line)) {
|
||||
return null;
|
||||
}
|
||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");
|
||||
fmt.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
|
||||
return fmt.format(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运出站齐套检查。地名栏允许空串,禁止再拿 destination 拦查价。
|
||||
* 报价日期非必填,空着也算出站齐套。
|
||||
*/
|
||||
static List<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) {
|
||||
missing.add("workOrderNo");
|
||||
}
|
||||
if (quoteDate == null || quoteDate.isEmpty()) {
|
||||
missing.add("quoteDate");
|
||||
}
|
||||
// 报价日期非必填。空着不计入 missing;参数留给已有调用方,避免再按缺日期拦截。
|
||||
log.debug("air.query dateOptional len={}", quoteDate == null ? 0 : quoteDate.length());
|
||||
for (String key : new String[] {
|
||||
"cargoName", "quantity", "weightKg", "volumeCbm", "packageType"
|
||||
}) {
|
||||
|
||||
+40
@@ -115,6 +115,12 @@ public final class TmsV2RequestMapper {
|
||||
put(target, "cargoReadyDate", first(source, "cargoReadyDate", "货好时间"));
|
||||
put(target, "placeOfReceipt", first(source, "placeOfReceipt", "提货地址"));
|
||||
put(target, "deliveryAddress", first(source, "deliveryAddress", "收货地址"));
|
||||
// 有增值服务才传列表。空列表不能出站,否则 TMS 会当成带了服务条件。
|
||||
List<String> addedServices = splitAddedServices(
|
||||
first(source, "addedServices", "added_services", "增值服务"));
|
||||
if (!addedServices.isEmpty()) {
|
||||
target.put("addedServices", addedServices);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +169,40 @@ public final class TmsV2RequestMapper {
|
||||
put(target, "customsPort", first(source, "customsPort"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运增值服务拆成多项。
|
||||
* <p>
|
||||
* 工单里是 added_services 一句话,TMS 要 addedServices 列表。
|
||||
* 顿号、中文逗号、英文逗号都拆;空格不拆,MUST GO 仍是一项。
|
||||
* 已经是列表则逐项原样保留。空项丢掉,不改词。
|
||||
*/
|
||||
public static List<String> splitAddedServices(Object raw) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (raw instanceof List) {
|
||||
for (Object item : (List<?>) raw) {
|
||||
addServiceToken(out, item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
String text = textOf(raw);
|
||||
if (text.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
for (String part : text.split("[、,,]")) {
|
||||
addServiceToken(out, part);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 去掉空白后并入列表。同一词只留第一次。 */
|
||||
private static void addServiceToken(List<String> out, Object raw) {
|
||||
String token = textOf(raw);
|
||||
if (token.isEmpty() || out.contains(token)) {
|
||||
return;
|
||||
}
|
||||
out.add(token);
|
||||
}
|
||||
|
||||
private static String textOf(Object raw) {
|
||||
return raw == null ? "" : String.valueOf(raw).trim();
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- 协商中回执:由「三个工作日 9:00」改为「N 个工作小时 + 具体时刻」。
|
||||
-- 变量 followup_hours=顺延小时文案;followup_when=再问时刻(如 9月24日15:30)。
|
||||
-- 仅改 inquiry_robot;幂等。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
UPDATE `inq_bot_reply`
|
||||
SET `reply_text` = '已记录为「协商中」。已顺延 {{followup_hours}} 个工作小时,将于 {{followup_when}} 再次发起成交状态询问。',
|
||||
`update_by` = 'system',
|
||||
`update_time` = NOW()
|
||||
WHERE `reply_code` = 'negotiating.recorded'
|
||||
OR `id` = 'brv10000000000000000000000000039'
|
||||
OR `trigger_scene` = '成交跟进中选择「协商中」';
|
||||
+1
-1
@@ -67,7 +67,7 @@ VALUES
|
||||
('brv10000000000000000000000000036','quoted','已报价','quoted.template_missing','后台未维护报价模板','生成报价单失败,请联系IT运维维护后台报价模板。',1,4,'admin',NOW()),
|
||||
('brv10000000000000000000000000037','quoted','已报价','quoted.file_ready','最终报价单生成完成','已生成{{kind}}最终报价单:{{filename}}(工单 {{ticket_no}})',1,5,'admin',NOW()),
|
||||
('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延 {{followup_hours}} 个工作小时,将于 {{followup_when}} 再次发起成交状态询问。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。',1,2,'admin',NOW()),
|
||||
('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()),
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
-- 协商中再问成交:按工作小时顺延(默认 48),后台「超时响应」页可改。
|
||||
-- 编码 deal_negotiate;运输方式 DEAL(成交跟进分块);0=不排再问。
|
||||
-- 仅改 inquiry_robot;幂等可重复执行。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO `inq_response_timeout` (
|
||||
`id`, `timeout_code`, `transport_mode`, `role_code`, `route_category`,
|
||||
`transport_type`, `timeout_hours`, `sort_no`, `create_by`, `create_time`, `update_by`, `update_time`
|
||||
)
|
||||
SELECT
|
||||
'rt100000000000000000000000000030',
|
||||
'deal_negotiate',
|
||||
'DEAL',
|
||||
'sales',
|
||||
'',
|
||||
CONVERT(UNHEX('e58d8fe59586e4b8ade5868de997aee68890e4baa4') USING utf8mb4),
|
||||
48,
|
||||
5,
|
||||
'system',
|
||||
NOW(),
|
||||
'system',
|
||||
NOW()
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (
|
||||
SELECT `id` FROM `inq_response_timeout` WHERE `timeout_code`='deal_negotiate'
|
||||
) AS `existed`
|
||||
);
|
||||
+195
-1
@@ -1480,6 +1480,81 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610080043:TMS weightPrices 只有 min/max、整票 costPrice=18200(计价单位:次)。
|
||||
* 询价毛重 2KG 落入 2-500KG 档时写入 18200;其它公斤列保持空;有真实档内单价时不走此回退。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageEmptyWeightBandsWritesFlatFeeIntoCargoBandOnly() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-groupage-flat", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-groupage-flat-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境集拼");
|
||||
org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7);
|
||||
matrix.createCell(1).setCellValue("始发地");
|
||||
matrix.createCell(2).setCellValue("目的地");
|
||||
matrix.createCell(3).setCellValue("最低收费");
|
||||
matrix.createCell(4).setCellValue("2-500KG");
|
||||
matrix.createCell(5).setCellValue("501-1000KG");
|
||||
matrix.createCell(6).setCellValue("1001-3000KG");
|
||||
matrix.createCell(7).setCellValue("3001-5000KG");
|
||||
matrix.createCell(8).setCellValue("5001KG以上");
|
||||
row(sheet, 9);
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject route = new JSONObject(true);
|
||||
route.put("origin", "凉山-春疆货场");
|
||||
route.put("destination", "湖北省-武汉市");
|
||||
route.put("currency", "CNY");
|
||||
route.put("costPrice", "18200.00");
|
||||
route.put("amount", "18200.00");
|
||||
route.put("weightPrices", JSONArray.parseArray(
|
||||
"[{\"min\":2,\"max\":500},{\"min\":501,\"max\":1000},{\"min\":1001,\"max\":3000},"
|
||||
+ "{\"min\":3001,\"max\":5000},{\"min\":5001}]"));
|
||||
JSONArray routeRows = new JSONArray();
|
||||
routeRows.add(route);
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境集拼");
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject region = new JSONObject(true);
|
||||
region.put("headerRow", 8);
|
||||
region.put("dataStartRow", 10);
|
||||
JSONObject columns = new JSONObject(true);
|
||||
columns.put("origin", "B");
|
||||
columns.put("destination", "C");
|
||||
JSONObject vehicleRates = new JSONObject(true);
|
||||
vehicleRates.put("最低收费", "D");
|
||||
vehicleRates.put("2-500KG", "E");
|
||||
vehicleRates.put("501-1000KG", "F");
|
||||
vehicleRates.put("1001-3000KG", "G");
|
||||
vehicleRates.put("3001-5000KG", "H");
|
||||
vehicleRates.put("5001KG以上", "I");
|
||||
columns.put("vehicleRates", vehicleRates);
|
||||
region.put("columns", columns);
|
||||
regions.add(region);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("毛重", "2");
|
||||
facts.put("route_rows", routeRows);
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("凉山-春疆货场", read(sheet, 9, 1));
|
||||
assertEquals("湖北省-武汉市", read(sheet, 9, 2));
|
||||
assertEquals("18200", normalizeMoney(read(sheet, 9, 3)));
|
||||
assertEquals("18200", normalizeMoney(read(sheet, 9, 4)));
|
||||
assertEquals("", read(sheet, 9, 5));
|
||||
assertEquals("", read(sheet, 9, 6));
|
||||
assertEquals("", read(sheet, 9, 7));
|
||||
assertEquals("", read(sheet, 9, 8));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销售没填客户名称时,客户名称格保持空白,不能写成业务员。
|
||||
* 询价条带的时效写在表头下一行;公斤分档价按 weightPrices 落入各列。
|
||||
@@ -1878,6 +1953,7 @@ public class QuoteTemplateFillTest {
|
||||
|
||||
/**
|
||||
* 跨境整车分段切片后仍须带上车型与运价;尺寸副表头行不能被 4000 盖掉,运价写在下一行。
|
||||
* WO006:单腿 vehicleType=3T 时,国内写 3T 列,国外段换算成 3.5T 再写,不能留空。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlKeepsVehiclePriceAfterOdSlice() throws Exception {
|
||||
@@ -1900,9 +1976,11 @@ public class QuoteTemplateFillTest {
|
||||
head2.createCell(2).setCellValue("目的地");
|
||||
head2.createCell(3).setCellValue("1.25T");
|
||||
head2.createCell(4).setCellValue("2.5T");
|
||||
head2.createCell(5).setCellValue("3.5T");
|
||||
org.apache.poi.ss.usermodel.Row dim2 = row(sheet, 27);
|
||||
dim2.createCell(3).setCellValue("2.8x1.5x1.6M");
|
||||
dim2.createCell(4).setCellValue("3.50x1.6x1.6M");
|
||||
dim2.createCell(5).setCellValue("4.2x1.8x1.8M");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
@@ -1933,6 +2011,7 @@ public class QuoteTemplateFillTest {
|
||||
JSONObject rates2 = new JSONObject(true);
|
||||
rates2.put("1.25T", "D");
|
||||
rates2.put("2.5T", "E");
|
||||
rates2.put("3.5T", "F");
|
||||
cols2.put("vehicleRates", rates2);
|
||||
overseas.put("columns", cols2);
|
||||
regions.add(overseas);
|
||||
@@ -1950,12 +2029,14 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
|
||||
assertEquals("广东省-深圳市", read(sheet, 9, 1));
|
||||
assertEquals("", read(sheet, 9, 2));
|
||||
assertEquals("4000.00", read(sheet, 9, 3));
|
||||
assertEquals("4000", normalizeMoney(read(sheet, 9, 3)));
|
||||
assertEquals("", read(sheet, 9, 4));
|
||||
assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3));
|
||||
assertEquals("", read(sheet, 28, 1));
|
||||
assertEquals("谅山-春疆货场", read(sheet, 28, 2));
|
||||
assertEquals("", read(sheet, 28, 3));
|
||||
assertEquals("", read(sheet, 28, 4));
|
||||
assertEquals("4000", normalizeMoney(read(sheet, 28, 5)));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
@@ -2080,6 +2161,107 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610120001:route_rows 混有多条备选(不同 segmentSequence),国内/境外段应写完整两段 OD,
|
||||
* 不能因「整表必须同序列」退回切片导致口岸列空。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlFillsFullOdFromMultiAltRouteRows() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-cb-wo001-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-cb-wo001-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境整车");
|
||||
row(sheet, 5).createCell(0).setCellValue("一、国内段报价");
|
||||
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7);
|
||||
head1.createCell(1).setCellValue("起始地");
|
||||
head1.createCell(2).setCellValue("目的地");
|
||||
head1.createCell(3).setCellValue("3T");
|
||||
row(sheet, 24).createCell(0).setCellValue("二、境外段报价");
|
||||
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26);
|
||||
head2.createCell(1).setCellValue("起始地");
|
||||
head2.createCell(2).setCellValue("目的地");
|
||||
head2.createCell(3).setCellValue("3.5T");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境整车");
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject inland = new JSONObject(true);
|
||||
inland.put("title", "国内段报价");
|
||||
inland.put("headerRow", 8);
|
||||
inland.put("dataStartRow", 8);
|
||||
JSONObject cols1 = new JSONObject(true);
|
||||
cols1.put("origin", "B");
|
||||
cols1.put("destination", "C");
|
||||
cols1.put("3T", "D");
|
||||
inland.put("columns", cols1);
|
||||
regions.add(inland);
|
||||
JSONObject overseas = new JSONObject(true);
|
||||
overseas.put("title", "境外段报价");
|
||||
overseas.put("headerRow", 27);
|
||||
overseas.put("dataStartRow", 27);
|
||||
JSONObject cols2 = new JSONObject(true);
|
||||
cols2.put("origin", "B");
|
||||
cols2.put("destination", "C");
|
||||
cols2.put("3.5T", "D");
|
||||
overseas.put("columns", cols2);
|
||||
regions.add(overseas);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "上海");
|
||||
facts.put("destination", "胡志明");
|
||||
JSONArray rows = new JSONArray();
|
||||
JSONObject a1 = new JSONObject(true);
|
||||
a1.put("segmentSequence", "1");
|
||||
a1.put("routeLegSequence", "1");
|
||||
a1.put("origin", "上海");
|
||||
a1.put("destination", "谅山-春疆货场");
|
||||
a1.put("vehicleType", "3T");
|
||||
a1.put("costPrice", "7500");
|
||||
a1.put("currency", "CNY");
|
||||
rows.add(a1);
|
||||
JSONObject a2 = new JSONObject(true);
|
||||
a2.put("segmentSequence", "1");
|
||||
a2.put("routeLegSequence", "2");
|
||||
a2.put("origin", "谅山-春疆货场");
|
||||
a2.put("destination", "胡志明");
|
||||
a2.put("vehicleType", "3.5T");
|
||||
a2.put("costPrice", "9600");
|
||||
a2.put("currency", "CNY");
|
||||
rows.add(a2);
|
||||
JSONObject b1 = new JSONObject(true);
|
||||
b1.put("segmentSequence", "2");
|
||||
b1.put("routeLegSequence", "1");
|
||||
b1.put("origin", "上海");
|
||||
b1.put("destination", "凭祥友谊关口岸");
|
||||
b1.put("vehicleType", "3T");
|
||||
b1.put("costPrice", "7000");
|
||||
rows.add(b1);
|
||||
JSONObject b2 = new JSONObject(true);
|
||||
b2.put("segmentSequence", "2");
|
||||
b2.put("routeLegSequence", "2");
|
||||
b2.put("origin", "凭祥友谊关口岸");
|
||||
b2.put("destination", "胡志明");
|
||||
b2.put("vehicleType", "3T");
|
||||
b2.put("costPrice", "9000");
|
||||
rows.add(b2);
|
||||
facts.put("route_rows", rows);
|
||||
facts.put("landSegments", rows);
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("上海", read(sheet, 8, 1));
|
||||
assertEquals("谅山-春疆货场", read(sheet, 8, 2));
|
||||
assertEquals("谅山-春疆货场", read(sheet, 27, 1));
|
||||
assertEquals("胡志明", read(sheet, 27, 2));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610080006:跨境整车上传版表头是「时效 | 币种(错行) | 燃油 | 税率 | 含税合计」。
|
||||
* 起始地不能被费用名「运输费」盖掉;CNY 只能进币种列;燃油附加费率要写到燃油列。
|
||||
@@ -2620,6 +2802,18 @@ public class QuoteTemplateFillTest {
|
||||
return cell.getStringCellValue();
|
||||
}
|
||||
|
||||
/** 金额格可能是数字 18200.0 或文本 18200.00,统一成去尾零纯数字再比。 */
|
||||
private static String normalizeMoney(String raw) {
|
||||
if (raw == null || raw.isBlank() || "-".equals(raw.trim())) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return new java.math.BigDecimal(raw.trim()).stripTrailingZeros().toPlainString();
|
||||
} catch (RuntimeException ex) {
|
||||
return raw.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运运价表:燃油附加费率、税率必须写到表头同列的数据行。
|
||||
* rate=0.09→9%;rateFlag 是是否含税,不写进税率格。
|
||||
|
||||
+23
@@ -92,4 +92,27 @@ public class InquiryTmsQueryServiceTest {
|
||||
"WO202609220004", "2026-09-25", segment);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void airQueryAllowsBlankQuoteDate() {
|
||||
// 空运没填报价日期也算出站齐套,不能再补成当天才放行。
|
||||
java.util.Map<String, Object> segment = new java.util.HashMap<String, Object>();
|
||||
segment.put("cargoName", "茶叶");
|
||||
segment.put("quantity", 1);
|
||||
segment.put("weightKg", "1.5");
|
||||
segment.put("volumeCbm", "100");
|
||||
segment.put("packageType", "托盘");
|
||||
java.util.List<String> missing = InquiryTmsQueryService.missingAirQuery(
|
||||
"WO202609220004", null, segment);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing));
|
||||
org.junit.jupiter.api.Assertions.assertNull(
|
||||
InquiryTmsQueryService.resolveOutboundQuoteDate("AIR", null));
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"2026-10-08",
|
||||
InquiryTmsQueryService.resolveOutboundQuoteDate("AIR", "2026-10-08"));
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(
|
||||
InquiryTmsQueryService.resolveOutboundQuoteDate("SEA", null));
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(
|
||||
InquiryTmsQueryService.resolveOutboundQuoteDate("LAND", ""));
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -193,6 +193,31 @@ public class TmsV2RequestMapperTest {
|
||||
assertFalse(out.containsKey("tradeTerms"));
|
||||
assertFalse(out.containsKey("贸易条款"));
|
||||
assertNull(out.get("originCode"));
|
||||
assertFalse(out.containsKey("addedServices"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void airAddedServicesSplitIntoList() {
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("destinationCode", "KUL");
|
||||
seg.put("cargoName", "茶叶");
|
||||
seg.put("quantity", "10");
|
||||
seg.put("weightKg", "100");
|
||||
seg.put("volumeCbm", "2");
|
||||
seg.put("packageType", "纸箱");
|
||||
seg.put("added_services", "托盘、前置仓,带电");
|
||||
Map<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "AIR");
|
||||
internal.put("quoteDate", "2026-10-13");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals(java.util.Arrays.asList("托盘", "前置仓", "带电"), segs.get(0).get("addedServices"));
|
||||
assertFalse(segs.get(0).containsKey("added_services"));
|
||||
assertEquals(
|
||||
java.util.Arrays.asList("MUST GO", "带电"),
|
||||
TmsV2RequestMapper.splitAddedServices("MUST GO、带电"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
changePassword,
|
||||
deleteUser,
|
||||
getAllRolesListNoByTenant,
|
||||
getExportUrl,
|
||||
getUserRoles,
|
||||
list as listUsers,
|
||||
saveOrUpdateUser,
|
||||
} from '/@/views/system/user/user.api';
|
||||
import { downloadAuthGet } from '/@/views/inquiry/shared/inquiryAuthDownload';
|
||||
|
||||
export {
|
||||
changePassword,
|
||||
@@ -20,6 +22,12 @@ export {
|
||||
saveOrUpdateUser,
|
||||
};
|
||||
|
||||
/**
|
||||
* 按当前筛选导出账号 Excel(Jeecg /sys/user/exportXls,与列表 QueryGenerator 同源)。
|
||||
*/
|
||||
export const exportAccounts = (params: Record<string, any>) =>
|
||||
downloadAuthGet(getExportUrl, params, '账号列表.xlsx');
|
||||
|
||||
export type AccountRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { message, Modal } from "ant-design-vue";
|
||||
import {
|
||||
changePassword,
|
||||
deleteUser,
|
||||
exportAccounts,
|
||||
getAllRolesListNoByTenant,
|
||||
getUserRoles,
|
||||
listUsers,
|
||||
@@ -25,6 +26,10 @@ import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||
const { hasPermission } = usePermission();
|
||||
/** 账号新建/编辑/删除/重置密码均属 edit 权限 */
|
||||
const canEditAccount = () => hasPermission(InquiryBtnPerm.accountEdit);
|
||||
/** 能看账号页即可导出(与列表同源筛选) */
|
||||
const canExportAccount = () =>
|
||||
hasPermission(InquiryBtnPerm.accountView) || hasPermission(InquiryBtnPerm.accountEdit);
|
||||
const exporting = ref(false);
|
||||
|
||||
const rows = ref<AccountRow[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -68,27 +73,34 @@ async function loadRoles() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 与列表同源的 Jeecg 查询参数(不含分页)。 */
|
||||
function buildAccountFilterParams(): Record<string, any> {
|
||||
const kw = filters.keyword.trim();
|
||||
const params: Record<string, any> = {};
|
||||
// Jeecg QueryGenerator:字段值包 * 走 LIKE。多字段 AND 过严,按关键字形态选一个字段。
|
||||
if (kw) {
|
||||
if (/[\u4e00-\u9fff]/.test(kw)) {
|
||||
params.realname = `*${kw}*`;
|
||||
} else if (/^\d{5,}$/.test(kw)) {
|
||||
params.phone = `*${kw}*`;
|
||||
} else {
|
||||
params.username = `*${kw}*`;
|
||||
}
|
||||
}
|
||||
if (filters.status === "active") params.status = 1;
|
||||
if (filters.status === "disabled") params.status = 2;
|
||||
return params;
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (!roleOptions.value.length) await loadRoles();
|
||||
const kw = filters.keyword.trim();
|
||||
// Jeecg QueryGenerator:字段值包 * 走 LIKE。多字段 AND 过严,按关键字形态选一个字段。
|
||||
const params: Record<string, any> = {
|
||||
...buildAccountFilterParams(),
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
if (kw) {
|
||||
if (/[\u4e00-\u9fff]/.test(kw)) {
|
||||
params.realname = `*${kw}*`;
|
||||
} else if (/^\d{5,}$/.test(kw)) {
|
||||
params.phone = `*${kw}*`;
|
||||
} else {
|
||||
params.username = `*${kw}*`;
|
||||
}
|
||||
}
|
||||
if (filters.status === "active") params.status = 1;
|
||||
if (filters.status === "disabled") params.status = 2;
|
||||
|
||||
const res: any = await listUsers(params);
|
||||
const page = res?.records ? res : res?.result || {};
|
||||
@@ -297,13 +309,37 @@ function removeAccount(row: AccountRow) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 按当前筛选导出账号 Excel(Jeecg 用户导出)。 */
|
||||
async function exportAccountList() {
|
||||
if (!canExportAccount()) {
|
||||
message.warning("无导出权限");
|
||||
return;
|
||||
}
|
||||
if (exporting.value) return;
|
||||
exporting.value = true;
|
||||
try {
|
||||
await exportAccounts(buildAccountFilterParams());
|
||||
message.success("已开始下载 Excel");
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || "导出失败");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport defer to="#page-topbar-actions">
|
||||
<div class="account-topbar-actions">
|
||||
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
|
||||
<ProtoIcon name="download" :size="14" />导出
|
||||
<button
|
||||
v-if="canExportAccount()"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="exporting"
|
||||
@click="exportAccountList"
|
||||
>
|
||||
<ProtoIcon name="download" :size="14" />{{ exporting ? "导出中…" : "导出" }}
|
||||
</button>
|
||||
<button v-if="canEditAccount()" type="button" class="btn btn-primary" @click="openCreate">
|
||||
<ProtoIcon name="plus" :size="14" />新建账号
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 询价后台列表分页条:总数 + 每页条数 + 页码。
|
||||
* 询价后台列表分页条:整组靠右,总数紧挨在「每页」左边,页码在后。
|
||||
* 样式对齐 admin-fidelity.css 的 .pagination;翻页只发事件,不请求接口。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
@@ -87,13 +87,13 @@ function onSizeChange(e: Event) {
|
||||
|
||||
<template>
|
||||
<div class="pagination admin-pagination">
|
||||
<span class="admin-pagination__total">
|
||||
共 <b>{{ total }}</b> {{ unit }}
|
||||
<span v-if="total > 0" class="admin-pagination__range">
|
||||
(第 {{ pageNo }} / {{ totalPages }} 页)
|
||||
</span>
|
||||
</span>
|
||||
<div class="admin-pagination__right">
|
||||
<span class="admin-pagination__total">
|
||||
共 <b>{{ total }}</b> {{ unit }}
|
||||
<span v-if="total > 0" class="admin-pagination__range">
|
||||
(第 {{ pageNo }} / {{ totalPages }} 页)
|
||||
</span>
|
||||
</span>
|
||||
<label class="admin-pagination__size">
|
||||
每页
|
||||
<select :value="pageSize" :disabled="disabled" @change="onSizeChange">
|
||||
@@ -127,6 +127,11 @@ function onSizeChange(e: Event) {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-pagination__total {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-pagination__size {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 分析看板 API。
|
||||
* 显式传 dateFrom/dateTo,与工单列表日期筛选同源;range 仅作无日期时的回退。
|
||||
*/
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
@@ -7,5 +8,20 @@ enum Api {
|
||||
metrics = '/inquiry/dashboard/metrics',
|
||||
}
|
||||
|
||||
export const getDashboardMetrics = (range = 'today') =>
|
||||
defHttp.get({ url: Api.metrics, params: { range } });
|
||||
export type DashboardMetricsParams = {
|
||||
range?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
};
|
||||
|
||||
export const getDashboardMetrics = (params: DashboardMetricsParams | string = 'today') => {
|
||||
const query =
|
||||
typeof params === 'string'
|
||||
? { range: params }
|
||||
: {
|
||||
range: params.range || 'today',
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
};
|
||||
return defHttp.get({ url: Api.metrics, params: query });
|
||||
};
|
||||
|
||||
@@ -56,11 +56,15 @@ const activePoint = ref<string | null>(null);
|
||||
const INQUIRY_PLOT_TOP = 36;
|
||||
const INQUIRY_PLOT_BOTTOM = 188;
|
||||
|
||||
/**
|
||||
* 粗粒度 range 仅作后端无 dateFrom/dateTo 时的回退;正式请求一律带显式日期。
|
||||
* 注意:不可再把「单日」映射成 today——today 是日历今天,不是所选那一天。
|
||||
*/
|
||||
function resolveRange(): string {
|
||||
const from = new Date(dateFrom.value);
|
||||
const to = new Date(dateTo.value);
|
||||
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
|
||||
if (days <= 0) return "today";
|
||||
if (days <= 0) return "1d";
|
||||
if (days <= 6) return "7d";
|
||||
return "30d";
|
||||
}
|
||||
@@ -88,7 +92,12 @@ function onDateRangeChange() {
|
||||
async function loadMetrics() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await getDashboardMetrics(resolveRange());
|
||||
const req = {
|
||||
range: resolveRange(),
|
||||
dateFrom: dateFrom.value,
|
||||
dateTo: dateTo.value,
|
||||
};
|
||||
const res: any = await getDashboardMetrics(req);
|
||||
const data = res?.result || res || {};
|
||||
todayMetrics.value = (data.metrics || []) as MetricCard[];
|
||||
inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({
|
||||
|
||||
@@ -320,7 +320,7 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
id: "brv10000000000000000000000000039",
|
||||
replyCode: "negotiating.recorded",
|
||||
trigger: "成交跟进中选择「协商中」",
|
||||
text: "已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。",
|
||||
text: "已记录为「协商中」。已顺延 {{followup_hours}} 个工作小时,将于 {{followup_when}} 再次发起成交状态询问。",
|
||||
enabled: true,
|
||||
}
|
||||
],
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 带鉴权下载二进制文件(Excel 等)。
|
||||
* 为何:window.open 带 token 遇 JSON 错误页难提示;统一走 fetch + blob。
|
||||
*/
|
||||
import { useGlobSetting } from '/@/hooks/setting';
|
||||
import { getToken } from '/@/utils/auth';
|
||||
|
||||
export type AuthDownloadParams = Record<string, string | number | undefined | null>;
|
||||
|
||||
/**
|
||||
* GET 下载附件并触发浏览器保存。
|
||||
* @param path 相对 API 路径,如 /inquiry/workOrder/view/export
|
||||
* @param params query 参数(空值跳过)
|
||||
* @param fallbackName 无 Content-Disposition 时的文件名
|
||||
*/
|
||||
export async function downloadAuthGet(
|
||||
path: string,
|
||||
params: AuthDownloadParams = {},
|
||||
fallbackName = 'download.bin',
|
||||
): Promise<void> {
|
||||
const glob = useGlobSetting();
|
||||
const base = (glob.apiUrl || '').replace(/\/$/, '');
|
||||
const qs = new URLSearchParams();
|
||||
Object.keys(params).forEach((k) => {
|
||||
const v = params[k];
|
||||
if (v === undefined || v === null || String(v).trim() === '') return;
|
||||
qs.set(k, String(v));
|
||||
});
|
||||
const url = `${base}${path}${qs.toString() ? `?${qs.toString()}` : ''}`;
|
||||
const token = getToken() || '';
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Access-Token': String(token),
|
||||
},
|
||||
});
|
||||
const ctype = (res.headers.get('content-type') || '').toLowerCase();
|
||||
if (!res.ok || ctype.includes('application/json')) {
|
||||
let msg = `下载失败 HTTP ${res.status}`;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j?.message) msg = String(j.message);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
let name = fallbackName;
|
||||
const cd = res.headers.get('content-disposition') || '';
|
||||
const m = /filename\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i.exec(cd);
|
||||
if (m) {
|
||||
name = decodeURIComponent((m[1] || m[2] || name).trim());
|
||||
}
|
||||
const href = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(href);
|
||||
}
|
||||
@@ -918,6 +918,10 @@ body {
|
||||
color: #155eef !important;
|
||||
}
|
||||
|
||||
.pagination.admin-pagination {
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
|
||||
.pagination .pages {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 超时响应:响应超时小时配置。
|
||||
* 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
|
||||
* 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。
|
||||
* 成交跟进 / 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
|
||||
* 「编码」= timeoutCode,每条唯一(如 deal_negotiate / sea_sales),供智能体单码调用;只读。
|
||||
* roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。
|
||||
* 陆运产品运输分类 5 项(与线路解耦);不展示线路类别。
|
||||
* 协商中再问读 deal_negotiate(工作小时,非工作日不算);0=不排再问。
|
||||
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
@@ -19,8 +20,9 @@ import { InquiryBtnPerm } from '/@/views/inquiry/shared/inquiryPagePerms';
|
||||
const { hasPermission } = usePermission();
|
||||
const canEditTimeout = () => hasPermission(InquiryBtnPerm.timeoutEdit);
|
||||
|
||||
/** 分块顺序:海运 → 空运 → 陆运(产品确认同页分块) */
|
||||
/** 分块顺序:成交跟进 → 海运 → 空运 → 陆运(产品确认同页分块) */
|
||||
const MODE_BLOCKS = [
|
||||
{ code: 'DEAL', title: '成交跟进' },
|
||||
{ code: 'SEA', title: '海运' },
|
||||
{ code: 'AIR', title: '空运' },
|
||||
{ code: 'LAND', title: '陆运' },
|
||||
@@ -118,7 +120,7 @@ async function commitHours(row) {
|
||||
<template>
|
||||
<div class="sys-setting-page">
|
||||
<div class="toolbar-bar">
|
||||
<span class="hint">固定规则,仅可改超时小时(支持小数,0=不监测);编码唯一只读,供智能体调用;失焦或回车保存</span>
|
||||
<span class="hint">固定规则,仅可改超时小时(支持小数;响应超时 0=不监测,协商再问 0=不排再问);编码唯一只读;失焦或回车保存</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 超时响应配置 API。
|
||||
* 对接主账 /inquiry/responseTimeout;仅 listAll + 改小时数。
|
||||
* timeoutCode 每条唯一(如 sea_sales),智能体 POST /inquiry/agent/responseTimeout/byCode 单码查询。
|
||||
* timeoutCode 每条唯一(如 deal_negotiate / sea_sales),智能体 POST /inquiry/agent/responseTimeout/byCode 单码查询。
|
||||
* deal_negotiate=协商中再问工作小时(成交跟进分块)。
|
||||
*/
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
|
||||
@@ -830,7 +830,7 @@ async function downloadPreviewOrOriginal() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<div class="pagination admin-pagination">
|
||||
<span>
|
||||
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个模板
|
||||
</span>
|
||||
|
||||
@@ -177,6 +177,11 @@ export function buildDemandFields(ticket: any): DemandField[] {
|
||||
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
push(fields, "包装类型", pick(facts, "包装方式", "包装类型"));
|
||||
const added = pick(facts, "added_services", "增值服务");
|
||||
if (added) {
|
||||
// 没填不占一行,和空运群、建单确认一致。
|
||||
push(fields, "增值服务", added);
|
||||
}
|
||||
push(fields, "报价日期", quoteDate);
|
||||
} else {
|
||||
// 多段或未识别:至少把路线和货量列出来,避免整块空白。
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import {
|
||||
exportWorkOrders,
|
||||
getWorkOrderDetail,
|
||||
getWorkOrderList,
|
||||
retryAbnormal,
|
||||
@@ -30,6 +31,10 @@ const canCloseTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
|
||||
const canRetryTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
|
||||
const canViewDetail = () =>
|
||||
hasPermission(InquiryBtnPerm.workorderDetail) || hasPermission(InquiryBtnPerm.workorderList);
|
||||
/** 导出:有 export 或 list 均可(与主账 Logical.OR 对齐) */
|
||||
const canExportTicket = () =>
|
||||
hasPermission(InquiryBtnPerm.workorderExport) || hasPermission(InquiryBtnPerm.workorderList);
|
||||
const exporting = ref(false);
|
||||
|
||||
/**
|
||||
* 与主账 TicketStatuses 一致:仅询价中/已报价/协商中/转人工可关到「已关闭」。
|
||||
@@ -676,13 +681,47 @@ function retryTicket(row: TicketRow) {
|
||||
function refresh() {
|
||||
loadList().then(() => message.success("已刷新"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前筛选导出 Excel(与列表同源,不分页;主账上限 2 万行)。
|
||||
*/
|
||||
async function exportTickets() {
|
||||
if (!canExportTicket()) {
|
||||
message.warning("无导出权限");
|
||||
return;
|
||||
}
|
||||
if (exporting.value) return;
|
||||
exporting.value = true;
|
||||
try {
|
||||
await exportWorkOrders({
|
||||
keyword: filters.keyword.trim() || undefined,
|
||||
status: filters.status || undefined,
|
||||
eventException: filters.eventException || undefined,
|
||||
systemException: filters.systemException || undefined,
|
||||
bizType: filters.bizType || undefined,
|
||||
dateFrom: filters.dateFrom || undefined,
|
||||
dateTo: filters.dateTo || undefined,
|
||||
});
|
||||
message.success("已开始下载 Excel");
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || "导出失败");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport defer to="#page-topbar-actions">
|
||||
<div style="display: flex; align-items: center; gap: 12px">
|
||||
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
|
||||
<ProtoIcon name="download" :size="16" />导出工单
|
||||
<button
|
||||
v-if="canExportTicket()"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="exporting"
|
||||
@click="exportTickets"
|
||||
>
|
||||
<ProtoIcon name="download" :size="16" />{{ exporting ? "导出中…" : "导出工单" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="refresh">
|
||||
<ProtoIcon name="refresh" :size="16" />刷新
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* 询价工单 API:列表/详情/六态/异常。
|
||||
* 询价工单 API:列表/详情/六态/异常/导出。
|
||||
*/
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
import { downloadAuthGet } from '/@/views/inquiry/shared/inquiryAuthDownload';
|
||||
|
||||
enum Api {
|
||||
list = '/inquiry/workOrder/view/list',
|
||||
detail = '/inquiry/workOrder/view/detail',
|
||||
exportXls = '/inquiry/workOrder/view/export',
|
||||
transition = '/inquiry/workOrder/transition',
|
||||
abnormalHandle = '/inquiry/workOrder/abnormal/handle',
|
||||
abnormalRetry = '/inquiry/workOrder/abnormal/retry',
|
||||
@@ -18,3 +20,9 @@ export const getWorkOrderDetail = (workOrderNo: string) =>
|
||||
export const transitionWorkOrder = (data) => defHttp.post({ url: Api.transition, data });
|
||||
export const handleAbnormal = (data) => defHttp.post({ url: Api.abnormalHandle, data });
|
||||
export const retryAbnormal = (data) => defHttp.post({ url: Api.abnormalRetry, data });
|
||||
|
||||
/**
|
||||
* 按当前筛选导出 Excel(与列表同源参数,不分页)。
|
||||
*/
|
||||
export const exportWorkOrders = (params: Record<string, any>) =>
|
||||
downloadAuthGet(Api.exportXls, params, '询价工单.xlsx');
|
||||
|
||||
@@ -21,10 +21,44 @@ test("land FTL shows vehicle not pieces", () => {
|
||||
const map = Object.fromEntries(rows.map((r) => [r.label, r.value]));
|
||||
expect(map["运输方式"]).toBe("陆运");
|
||||
expect(map["运输类型"]).toBe("国内运输整车");
|
||||
expect(map["车型/数量"]).toBe("53尺(16.5厢车)");
|
||||
expect(map["始发站"]).toBe("上海");
|
||||
expect(map["货物品名"]).toBe("茶叶");
|
||||
expect(map["件数"]).toBeUndefined();
|
||||
expect(map["车型/数量"]).toBe("53尺(16.5厢车)");
|
||||
expect(map["始发站"]).toBe("上海");
|
||||
expect(map["货物品名"]).toBe("茶叶");
|
||||
expect(map["件数"]).toBeUndefined();
|
||||
expect(map["增值服务"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("air shows added services only when filled", () => {
|
||||
const filled = buildDemandFields({
|
||||
businessLine: "AIR",
|
||||
bizTypeLabel: "空运",
|
||||
factsJson: JSON.stringify({
|
||||
起运港: "深圳",
|
||||
目的港: "吉隆坡",
|
||||
品名: "茶叶",
|
||||
件数: "10",
|
||||
毛重: "100",
|
||||
体积: "2",
|
||||
包装方式: "纸箱",
|
||||
报价日期: "2026-10-13",
|
||||
added_services: "托盘、前置仓、带电",
|
||||
}),
|
||||
});
|
||||
const map = Object.fromEntries(filled.map((r) => [r.label, r.value]));
|
||||
expect(map["增值服务"]).toBe("托盘、前置仓、带电");
|
||||
expect(map["包装类型"]).toBe("纸箱");
|
||||
|
||||
const empty = buildDemandFields({
|
||||
businessLine: "AIR",
|
||||
bizTypeLabel: "空运",
|
||||
factsJson: JSON.stringify({
|
||||
目的港: "吉隆坡",
|
||||
品名: "茶叶",
|
||||
包装方式: "纸箱",
|
||||
}),
|
||||
});
|
||||
const emptyMap = Object.fromEntries(empty.map((r) => [r.label, r.value]));
|
||||
expect(emptyMap["增值服务"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sea shows container spec", () => {
|
||||
|
||||
@@ -40,9 +40,10 @@
|
||||
|
||||
- **海运**:起运港、目的港、品名、货量、整柜或拼柜、箱型箱量、报价日期
|
||||
箱型箱量是一个原始业务字段;结构化 `containerItems` 只由 DeepSeek B 投影。
|
||||
- **空运**:起运地、目的地、品名、件数、重量、体积、包装类型、报价日期
|
||||
- **空运**:起运地、目的地、品名、件数、重量、体积、包装类型。报价日期非必填
|
||||
- **空运非必填增值服务**:销售看到的名称是「增值服务」,工单字段 `added_services`,查价字段 `addedServices`。没填不挡查价、不传给 TMS。填了按多项原词传递;有报价就回报价,没有就回暂无报价。12 个词(托盘、前置仓、带电、卡转、卡转低消、MUST GO、备装、分泡、温控、快件、DG、TC)只要完整出现就记;名单外的必须销售标明增值服务才记。只写在包装类型里的「托盘」不算增值服务。增值服务这一行冒号后是空的,不能把下一行收进来。
|
||||
- **陆运**按二级类型:国内拼车/整车、跨境集拼/整车、中港(见合同 JSON),各类型均含报价日期
|
||||
- **报价日期**:三线查价都要,用来查 TMS 当天/指定日的价;销售没说则系统默认为当天(上海时区),不补问;销售说了用原话,不得用当天盖掉。
|
||||
- **报价日期**:海运、陆运查价都要,用来查 TMS 当天/指定日的价;销售没说则系统默认为当天(上海时区),不补问;销售说了用原话,不得用当天盖掉。空运非必填:说了就按这一天直接查价;没说不默认当天、不补问,确认内容写成「报价日期(非必填):」。确认后不带日期查一次价,把 TMS 返回的全部舱位发给销售点选(哪怕只有 1 条)。舱位展示起运地、目的地、航班日期、班期(TMS 原样,如 D234567)、航司、总价。选定后用该条总价出报价,不再查第二次;该条航班日期写回工单,作为报价日期。
|
||||
|
||||
协同阶段另有协同必填(贸易条款、HS、含电含磁等),点「协同」必须用 **不可变销售原话** 重跑模型 B,不得用卡片口令覆盖。
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ NormalizedInbound
|
||||
## 出站
|
||||
|
||||
- 主账:登录或 Token,所有写操作要幂等键(message_id / request_id / quoteVersion)。
|
||||
- 企微:只通过 outbox;失败重试;过期出站丢弃不计业务成功。询价与正式同一 Agent `1000010`;运维 `1000014`,测试**告警**正文加 `[test]`,销售询价确认不加 `[test]`、不加「询价卡片」标题。建单确认按空运询价必填 8 项:起运地、目的地、品名、件数、重量、体积、包装类型、报价日期(另加工程单号、运输方式),末行「工单已创建,正在查询TMS报价。」销售侧标签不得写成毛重、重量(KG)、体积(CBM)。缺项补问先回显已识别,再列待补充并给示例(重量如 100KGS、体积如 2CBM、包装如托盘),待补充行不加圆点;报价日期销售说了写入已识别,没说默认当天,不进待补充。起运地/目的地说了地名即采集完成,不得因对不上三字码再补问。每次出询价确认都新开一单再查 TMS,相同需求不复用旧工单号。包装等字段销售说什么就记什么,禁止白名单把散货等原文挡回去再补问。
|
||||
- 企微:只通过 outbox;失败重试;过期出站丢弃不计业务成功。询价与正式同一 Agent `1000010`;运维 `1000014`,测试**告警**正文加 `[test]`,销售询价确认不加 `[test]`、不加「询价卡片」标题。建单确认按空运询价必填项:起运地、目的地、品名、件数、重量、体积、包装类型(报价日期非必填;另加工程单号、运输方式),末行「工单已创建,正在查询TMS报价。」销售侧标签不得写成毛重、重量(KG)、体积(CBM)。缺项补问先回显已识别,再列待补充并给示例(重量如 100KGS、体积如 2CBM、包装如托盘),待补充行不加圆点;空运报价日期销售说了写入已识别并写成「报价日期:日期」;没说写成「报价日期(非必填):」,不默认当天,不进待补充。海运、陆运报价日期没说仍默认当天。起运地/目的地说了地名即采集完成,不得因对不上三字码再补问。每次出询价确认都新开一单再查 TMS,相同需求不复用旧工单号。包装等字段销售说什么就记什么,禁止白名单把散货等原文挡回去再补问。
|
||||
- 对象存储:S3 兼容;生成文件后登记主账附件再发企微。
|
||||
|
||||
## 测试
|
||||
|
||||
+4
-2
@@ -15,11 +15,13 @@
|
||||
|
||||
主账按 TMS V2 `/quote/v2/query` 把空运查价参数带全再出站。智能体必须参数齐全才建单、才出询价卡片。
|
||||
|
||||
空运出站必带:`requestId`(UUID)、`workOrderNo`(WOYYYYMMDDNNNN)、`transportMode=AIR`、`quoteDate`(yyyy-MM-dd)、`segments[0]` 的 origin/originCode/destination/destinationCode/cargoName、`quantity`(整数)、`weightKg`/`volumeCbm`(大于 0 的数字)、`packageType`。缺一项:补问,不出卡片,不出站。
|
||||
空运出站必带:`requestId`(UUID)、`workOrderNo`(WOYYYYMMDDNNNN)、`transportMode=AIR`、`segments[0]` 的 origin/originCode/destination/destinationCode/cargoName、`quantity`(整数)、`weightKg`/`volumeCbm`(大于 0 的数字)、`packageType`。`quoteDate`(yyyy-MM-dd)仅在销售说了报价日期时带上。缺必带项:补问,不出卡片,不出站。
|
||||
|
||||
- 销售未说报价日期则填当天,不补问该字段
|
||||
- 海运、陆运:销售未说报价日期则填当天,不补问该字段
|
||||
- 空运:销售未说报价日期则不传 `quoteDate`,不填当天。TMS 一次返回各航班日期舱位后由销售点选,选定舱位的航班日期再写回工单报价日期
|
||||
- 口语单位在出站前去掉;机场名能对应三字码则带上,销售已写三字码则原样带
|
||||
- 空运 `packageType` 必须是 TMS 字典码:1=全部(现网航线常无价)、2=托盘/散货/卡板、3=纸箱/木箱。销售卡片仍写原文;禁止把托盘编成 1
|
||||
- 空运增值服务:工单字段 `added_services`。有内容才在 `segments[0].addedServices` 传多项原词(顿号、中英文逗号都拆开,空格不拆)。没填不传该字段,不挡查价。TMS 有报价就回报价,没有就回暂无报价。
|
||||
|
||||
## 响应
|
||||
|
||||
|
||||
Reference in New Issue
Block a user