diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py
index 5a59331..1c6dcaf 100644
--- a/inquiry-agent/agent/ledger/http_ledger.py
+++ b/inquiry-agent/agent/ledger/http_ledger.py
@@ -40,8 +40,18 @@ class HttpLedger:
def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]:
resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem)
if not resp.ok:
- logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error)
- return {"ok": False, "error": resp.error or f"http_{resp.status_code}"}
+ # 500 时 Jeecg 仍可能带回 message(如六态非法);勿只留 http_500,
+ # 否则成交补「已报价」重试认不出「询价中」(WO023)。
+ data = resp.data if isinstance(resp.data, dict) else {}
+ msg = ""
+ if isinstance(data, dict):
+ msg = str(data.get("message") or "").strip()
+ unwrapped = _unwrap(data)
+ if unwrapped.get("error"):
+ msg = str(unwrapped.get("error") or msg)
+ err = msg or resp.error or f"http_{resp.status_code}"
+ logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, err)
+ return {"ok": False, "error": err}
return _unwrap(resp.data)
def find_similar_open(
@@ -166,8 +176,12 @@ class HttpLedger:
"""
no = (work_order_no or "").strip()
src = dict(quote or {})
- if not no or not src:
- return {"ok": True, "quote_version": 1, "status": to_status or "已报价"}
+ if not no:
+ return {"ok": False, "error": "work_order_blank"}
+ # 空报价不打主账(避免抹掉已有查价),但禁止伪装成「已写入已报价」——
+ # 否则会话以为已报价、主账仍停询价中,成交三态会失败(WO022)。
+ if not src:
+ return {"ok": False, "error": "quote_blank", "skipped": True}
out = self._post(
"/inquiry/agent/ticket/replaceTmsQuote",
{"workOrderNo": no, "quote": src, "toStatus": to_status},
@@ -423,6 +437,7 @@ class HttpLedger:
operator_name: str = "",
instruction_text: str = "",
airline: str = "",
+ flight_no: str = "",
) -> dict[str, Any]:
body: dict[str, Any] = {
"workOrderNo": work_order_no,
@@ -433,6 +448,8 @@ class HttpLedger:
}
if (airline or "").strip():
body["airline"] = airline.strip().upper()
+ if (flight_no or "").strip():
+ body["flightNo"] = flight_no.strip().upper()
return self._post(
"/inquiry/agent/tms/lockCabin",
body,
diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py
index 0e415e1..0bb49b3 100644
--- a/inquiry-agent/agent/ledger/memory_ledger.py
+++ b/inquiry-agent/agent/ledger/memory_ledger.py
@@ -827,9 +827,14 @@ class MemoryLedger:
operator_name: str = "",
instruction_text: str = "",
airline: str = "",
+ flight_no: str = "",
) -> dict[str, Any]:
- """单测可注入 _cabin_lock_result;默认成功。"""
- _ = sender_id, operator_name, instruction_text, airline
+ """单测可注入 _cabin_lock_result;默认成功。
+
+ 纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
+ 对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
+ """
+ _ = sender_id, operator_name, instruction_text
forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict):
return dict(forced)
@@ -845,6 +850,27 @@ class MemoryLedger:
quote_id = str(quote.get("quoteId") or "").strip()
if quote_id.upper().startswith("QT-WO"):
quote_id = ""
+ air_opts = quote.get("airOptions") or quote.get("air_options") or []
+ has_tms_cabin = bool(quote_id) or bool(air_opts)
+ opt = (option_no or "").strip()
+ air = (airline or "").strip()
+ fn = (flight_no or "").strip()
+ if not has_tms_cabin:
+ missing: list[str] = []
+ if not opt:
+ missing.append("舱位编号optionNo")
+ if not air:
+ missing.append("航司airline")
+ if not fn:
+ missing.append("航班号flightNo")
+ if missing:
+ return {
+ "ok": False,
+ "error": "lock_incomplete",
+ "failReason": "锁舱参数不完整,缺少:"
+ + "、".join(missing)
+ + "。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。",
+ }
return {
"ok": True,
"status": "LOCKED",
diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py
index 342a55c..b3fcfba 100644
--- a/inquiry-agent/agent/llm/extract_text.py
+++ b/inquiry-agent/agent/llm/extract_text.py
@@ -757,6 +757,30 @@ _RE_MONEY = re.compile(
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
re.I,
)
+# 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。
+_RE_WEIGHT_BAND_PRICE = re.compile(
+ r"(?P\d+)\s*[-~~—–到至]\s*(?P\d+)\s*K\s*G"
+ r"(?:\s*以上)?"
+ r"\s*(?:单价)?"
+ r"\s*(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s*)?"
+ r"(?P\d+(?:\.\d+)?)",
+ re.I,
+)
+_RE_WEIGHT_BAND_OPEN = re.compile(
+ r"(?P\d+)\s*K\s*G\s*以上"
+ r"\s*(?:单价)?"
+ r"\s*(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s*)?"
+ r"(?P\d+(?:\.\d+)?)",
+ re.I,
+)
+_RE_FUEL_SURCHARGE_RATE = re.compile(
+ r"燃油附加费率\s*[::=]?\s*(?P\d+(?:\.\d+)?)\s*%?",
+ re.I,
+)
+_RE_TAX_RATE = re.compile(
+ r"(?\d+(?:\.\d+)?)\s*%?",
+ re.I,
+)
_RE_FEE_LINE = re.compile(
r"(?P[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
r"\s*(?:改为|改成|调整为|调整成)?"
@@ -867,6 +891,8 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
认不出价格返回空 dict,调用方回「没有识别到报价」。
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
+ 陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
+ 禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
"""
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
out = dict(injected)
@@ -879,16 +905,22 @@ 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 {}
- rows = _parse_fee_rows(raw)
+ band = _parse_weight_band_quote(raw)
+ if band:
+ return band
+ # 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
+ scrubbed = _strip_rate_and_band_noise(raw)
+ rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else []
if rows:
ccy = rows[0].get("currency") or _currency_of(raw)
- total = rows[0]["amount"]
- if len({x.get("currency") for x in rows}) == 1:
+ 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:
try:
- total = str(sum(float(x["amount"]) for x in rows))
+ total = str(sum(float(x["amount"]) for x in money_rows))
except ValueError:
- total = rows[0]["amount"]
- return {
+ total = money_rows[0]["amount"]
+ out = {
"total": total,
"currency": ccy,
"source_label": "产品报价",
@@ -896,9 +928,12 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
"fee_rows": rows,
"fee_lines": list(rows),
}
- hit = _RE_MONEY.search(_ascii_amount(raw))
+ out.update(_extract_rate_fields(raw))
+ return out
+ hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw))
if not hit:
- return {}
+ rates = _extract_rate_fields(raw)
+ return rates if rates else {}
amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
if not amount:
return {}
@@ -906,12 +941,13 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
named = re.search(
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
- raw,
+ scrubbed or raw,
)
fee_name = (named.group(1) if named else "") or "海运费"
- if fee_name in {"费用"}:
- fee_name = "海运费"
- return {
+ if fee_name in {"费用", "燃油附加费"}:
+ # 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。
+ fee_name = "海运费" if fee_name == "费用" else "陆运费"
+ out = {
"total": amount,
"currency": ccy,
"source_label": "产品报价",
@@ -919,3 +955,113 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
}
+ out.update(_extract_rate_fields(raw))
+ return out
+
+
+def _format_percent_rate(raw: str) -> str:
+ """费率展示统一带 %;已有 % 保留。"""
+ text = (raw or "").strip()
+ if not text:
+ return ""
+ if text.endswith("%"):
+ return text
+ return f"{text}%"
+
+
+def _extract_rate_fields(text: str) -> dict[str, str]:
+ """抽出燃油附加费率 / 税率;只返回出现过的键。"""
+ out: dict[str, str] = {}
+ fuel = _RE_FUEL_SURCHARGE_RATE.search(text or "")
+ if fuel:
+ out["fuel_surcharge_rate"] = _format_percent_rate(fuel.group("rate") or "")
+ out["fuelSurchargeRate"] = out["fuel_surcharge_rate"]
+ tax = _RE_TAX_RATE.search(text or "")
+ if tax:
+ out["tax_rate"] = _format_percent_rate(tax.group("rate") or "")
+ out["taxRate"] = out["tax_rate"]
+ return out
+
+
+def _strip_rate_and_band_noise(text: str) -> str:
+ """去掉分档价与费率短语,避免后续费用行/金额回退误伤。"""
+ cleaned = _ascii_amount(text or "")
+ cleaned = _RE_WEIGHT_BAND_PRICE.sub(" ", cleaned)
+ cleaned = _RE_WEIGHT_BAND_OPEN.sub(" ", cleaned)
+ cleaned = _RE_FUEL_SURCHARGE_RATE.sub(" ", cleaned)
+ cleaned = _RE_TAX_RATE.sub(" ", cleaned)
+ return cleaned
+
+
+def _parse_weight_band_quote(text: str) -> dict[str, Any]:
+ """
+ 陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。
+
+ 产出 weightPrices,供主账出单按「2-500KG」表头对档;费率进顶层字段填 Excel。
+ """
+ raw = _ascii_amount(text or "")
+ tiers: list[dict[str, Any]] = []
+ rows: list[dict[str, str]] = []
+ ccy = ""
+ for hit in _RE_WEIGHT_BAND_PRICE.finditer(raw):
+ lo = int(hit.group("min") or 0)
+ hi = int(hit.group("max") or 0)
+ price = _ascii_amount(hit.group("price") or "")
+ if not price:
+ continue
+ line_ccy = _currency_of(raw, hit.group("ccy") or "")
+ ccy = ccy or line_ccy
+ label = f"{lo}-{hi}KG"
+ tiers.append({"min": lo, "max": hi, "price": price})
+ rows.append(
+ {
+ "name": label,
+ "amount": price,
+ "currency": line_ccy,
+ "unit_price": price,
+ }
+ )
+ for hit in _RE_WEIGHT_BAND_OPEN.finditer(raw):
+ lo = int(hit.group("min") or 0)
+ price = _ascii_amount(hit.group("price") or "")
+ if not price:
+ continue
+ line_ccy = _currency_of(raw, hit.group("ccy") or "")
+ ccy = ccy or line_ccy
+ label = f"{lo}KG以上"
+ tiers.append({"min": lo, "max": None, "price": price})
+ rows.append(
+ {
+ "name": label,
+ "amount": price,
+ "currency": line_ccy,
+ "unit_price": price,
+ }
+ )
+ if not tiers:
+ return {}
+ rates = _extract_rate_fields(raw)
+ # 费率行进明细方便群摘要展示;金额带 %,合计时只加数字价。
+ if rates.get("fuel_surcharge_rate"):
+ rows.append(
+ {
+ "name": "燃油附加费率",
+ "amount": rates["fuel_surcharge_rate"],
+ "currency": "",
+ }
+ )
+ if rates.get("tax_rate"):
+ rows.append({"name": "税率", "amount": rates["tax_rate"], "currency": ""})
+ # 分档是单价,不能把多档相加冒充费用合计。
+ out: dict[str, Any] = {
+ "total": tiers[0]["price"],
+ "currency": ccy or "CNY",
+ "source_label": "产品报价",
+ "raw_text": (text or "").strip(),
+ "fee_rows": rows,
+ "fee_lines": list(rows),
+ "weightPrices": tiers,
+ "price_per_kg": tiers[0]["price"],
+ }
+ out.update(rates)
+ return out
diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py
index cd0388b..d9f160c 100644
--- a/inquiry-agent/agent/policy/air_group_ops.py
+++ b/inquiry-agent/agent/policy/air_group_ops.py
@@ -3,7 +3,8 @@
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
-锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。
+锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也可走 MANUAL。
+禁止编造 optionNo;缺舱位编号/航司/计费重时先群里补问,禁止空壳打 TMS。
"""
from __future__ import annotations
@@ -776,22 +777,46 @@ def apply_air_deal(
if stopped:
return stopped
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
- from agent.policy.sea_group_ops import facts_of_group
+ from agent.policy.sea_group_ops import _quote_fee_rows, facts_of_group
view = ticket_view(ticket)
+ no = ticket_no(ticket)
+ quote = dict(view.get("quote") or {})
+ status_now = ticket_status(ticket)
+ usable_total = str(quote.get("total") or "").strip()
+ if usable_total in {"", "-"}:
+ usable_total = ""
+ # 与海运一致:询价中且无报价时先说清原因,禁止笼统「主账状态更新失败」。
+ if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
+ send_group_text(
+ group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
+ )
+ return "deal_need_quote"
inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
result = finish_deal_outcome(
ledger=flow.ledger,
- work_order_no=ticket_no(ticket),
+ work_order_no=no,
outcome=outcome,
facts=facts_of_group(view),
- quote=dict(view.get("quote") or {}),
+ quote=quote,
business_line="AIR",
sales_name=str(view.get("sales_name") or ""),
lost_reason=inline,
)
if not result.get("ok"):
- send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
+ # 迁态失败且仍无报价时,同样用「没有报价」说明,避免旧笼统句。
+ err = str(result.get("error") or "")
+ still_no_quote = not (
+ usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}
+ )
+ if still_no_quote or "询价中" in err:
+ send_group_text(
+ group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
+ )
+ else:
+ send_group_text(
+ group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。"
+ )
return "ledger_fail"
phase = str(result.get("phase") or "done")
ack_reason = inline if phase == "done" and outcome == "未成交" else ""
@@ -800,7 +825,7 @@ def apply_air_deal(
chat_id,
copy.deal_ack(
outcome,
- ticket_no(ticket),
+ no,
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
),
@@ -818,6 +843,13 @@ def apply_air_lock(
option_no: str = "",
airline: str = "",
) -> str:
+ """
+ 空运群锁舱。
+
+ 副作用:调主账 lockCabin(HMAC 出站 TMS);成功写协同 facts、推成交跟进。
+ 舱位/航司/航班交给主账现拉 TMS 补全;仅计费重全空时群里补问。
+ 主账仍缺则回 lock_incomplete,禁止空壳出站。
+ """
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
@@ -825,14 +857,21 @@ def apply_air_lock(
# 不能只回「TMS接口异常,IT运维排查中」。
no = ticket_no(ticket)
name = operator_name(flow, sender_id)
- view_quote = dict(ticket_view(ticket).get("quote") or {})
+ view = ticket_view(ticket)
+ view_quote = dict(view.get("quote") or {})
air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {})
quote = air_quote or view_quote
- options = _air_options(quote)
- quote_id = _quote_id(quote)
+ # 最新价常是人工覆盖:舱位/航司/航班要从本单 TMS 原价自动补,不能逼航线手打。
+ tms_quote = copy.resolve_tms_quote(None, view)
+ if not tms_quote:
+ tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
+ quote_options = _air_options(quote)
+ tms_options = _air_options(tms_quote)
+ options = quote_options or tms_options
+ quote_id = _quote_id(quote) or _quote_id(tms_quote)
picked = (option_no or "").strip()
if not picked and len(options) > 1:
- facts = dict(ticket_view(ticket).get("collab_facts") or {})
+ facts = dict(view.get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
@@ -841,6 +880,42 @@ def apply_air_lock(
return "wait_lock_option"
if not picked and len(options) == 1:
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
+ # 没有任何可锁报价时先拒绝,不要把「无报价」误说成缺舱位。
+ if not _quote_ready_for_lock(quote) and not _quote_ready_for_lock(tms_quote):
+ send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
+ return "no_quote"
+ airline_code = (airline or "").strip().upper()
+ if not airline_code:
+ airline_code = _airline_from_quote_or_option(quote, quote_options, picked)
+ if not airline_code:
+ airline_code = _airline_from_quote_or_option(tms_quote, tms_options, picked)
+ chargeable = _chargeable_weight_kg(quote, quote_options, picked, ticket)
+ if not chargeable:
+ chargeable = _chargeable_weight_kg(tms_quote, tms_options, picked, ticket)
+ flight_no = _flight_no_from_quote_or_option(quote, quote_options, picked, instruction)
+ if not flight_no:
+ flight_no = _flight_no_from_quote_or_option(tms_quote, tms_options, picked, instruction)
+ # 舱位/航司/航班交给主账:库里无 TMS 时会现拉补全;仍缺才回 lock_incomplete。
+ # 这里只拦计费重全空(工单毛重也没有)——否则会逼航线手打航班。
+ need_chargeable = not chargeable
+ if need_chargeable:
+ facts = dict(ticket_view(ticket).get("collab_facts") or {})
+ if str(facts.get(_PENDING_LOCK) or "") != "1":
+ flow.ledger.patch_collab_facts(
+ work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
+ )
+ send_group_text(
+ group_client_of(flow),
+ chat_id,
+ copy.air_ask_manual_lock_fields(
+ need_option=False,
+ need_airline=False,
+ need_chargeable=True,
+ need_flight=False,
+ options=options,
+ ),
+ )
+ return "wait_lock_fields"
locker = getattr(flow.ledger, "lock_cabin", None)
if not callable(locker):
send_group_text(
@@ -857,6 +932,14 @@ def apply_air_lock(
confirm_system_exception,
)
+ def _lock_terminal(x: object) -> bool:
+ """成功,或业务上不必再重试(缺舱位/无报价)。"""
+ row = x if isinstance(x, dict) else {}
+ if bool(row.get("ok")):
+ return True
+ err = str(row.get("error") or "")
+ return err in {"lock_incomplete", "no_quote"}
+
out = call_with_retries(
lambda: locker(
work_order_no=no,
@@ -864,15 +947,30 @@ def apply_air_lock(
sender_id=sender_id,
operator_name=name,
instruction_text=instruction,
- airline=(airline or "").strip(),
+ airline=airline_code,
+ flight_no=flight_no,
)
or {},
- is_ok=lambda x: bool((x or {}).get("ok")),
+ is_ok=_lock_terminal,
)
if not out.get("ok"):
if str(out.get("error") or "") == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
+ if str(out.get("error") or "") == "lock_incomplete":
+ # 主账现拉仍无舱位:业务补问,不算系统异常,不惊动 IT。
+ why = str(out.get("failReason") or out.get("error") or "锁舱参数不完整")
+ facts = dict(ticket_view(ticket).get("collab_facts") or {})
+ if str(facts.get(_PENDING_LOCK) or "") != "1":
+ flow.ledger.patch_collab_facts(
+ work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
+ )
+ send_group_text(
+ group_client_of(flow),
+ chat_id,
+ copy.air_lock_incomplete(work_order_no=no, operator=name, reason=why),
+ )
+ return "lock_incomplete"
why = copy.air_cabin_fail_reason(
tms_reason=str(out.get("failReason") or out.get("error") or ""),
flight_date=_ticket_flight_date(ticket),
@@ -895,6 +993,7 @@ def apply_air_lock(
conversation_target=chat_id,
extra={
"option_no": picked,
+ "airline": airline_code,
"instruction": instruction,
"sender_id": sender_id,
"operator_name": name,
@@ -1164,6 +1263,140 @@ def _quote_id(quote: dict[str, Any]) -> str:
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
+def _quote_ready_for_lock(quote: dict[str, Any]) -> bool:
+ """
+ 有没有可锁的报价:真实 TMS 编号,或人工总价/费用明细。
+ 与内存账本口径一致,禁止把空报价当成「缺舱位」去补问。
+ """
+ src = dict(quote or {})
+ label = str(src.get("source_label") or src.get("sourceLabel") or "")
+ if "确认" in label and "TMS" in label.upper():
+ qid = str(src.get("quoteId") or "").strip()
+ return bool(qid) and not qid.upper().startswith("QT-")
+ if str(src.get("total") or "").strip() not in {"", "-"}:
+ return True
+ if src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or src.get("departureCharges"):
+ return True
+ if _air_options(src):
+ return True
+ qid = str(src.get("quoteId") or src.get("quote_id") or "").strip()
+ return bool(qid) and not qid.upper().startswith("QT-WO")
+
+
+def _picked_option(
+ options: list[dict[str, Any]], option_no: str
+) -> dict[str, Any]:
+ """按 optionNo 取舱位;对不上返回空 dict。"""
+ want = (option_no or "").strip().upper()
+ if not want:
+ return {}
+ for row in options:
+ no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper()
+ if no == want:
+ return row
+ return {}
+
+
+def _airline_from_quote_or_option(
+ quote: dict[str, Any],
+ options: list[dict[str, Any]],
+ option_no: str,
+) -> str:
+ """
+ 锁舱航司:舱位 airlineCode > 报价顶层 > 航班号前两位。
+ 只回两位大写字母;猜不到就空,交给群里补问。
+ """
+ opt = _picked_option(options, option_no)
+ raw = str(
+ opt.get("airlineCode")
+ or opt.get("airline")
+ or quote.get("airlineCode")
+ or quote.get("airline")
+ or ""
+ ).strip().upper()
+ if len(raw) >= 2 and raw[:2].isalpha():
+ return raw[:2]
+ flight = str(
+ opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or ""
+ ).strip().upper()
+ if len(flight) >= 2 and flight[0].isalpha() and flight[1].isalpha():
+ return flight[:2]
+ name = str(opt.get("airlineName") or quote.get("airlineName") or "").strip()
+ # 名称里不硬猜二字码,避免「南航」编成乱码。
+ _ = name
+ return ""
+
+
+def _flight_no_from_quote_or_option(
+ quote: dict[str, Any],
+ options: list[dict[str, Any]],
+ option_no: str,
+ instruction: str = "",
+) -> str:
+ """航班号:舱位/报价字段,或句中「航班CZ327」「CZ327」形态。"""
+ import re
+
+ opt = _picked_option(options, option_no)
+ raw = str(opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or "").strip()
+ if raw:
+ return raw.upper()
+ blob = (instruction or "").upper()
+ m = re.search(r"(?:航班|FLIGHT)\s*([A-Z0-9]{2,8})", blob)
+ if m:
+ return m.group(1)
+ m = re.search(r"\b([A-Z]{2}\d{2,4})\b", blob)
+ if m:
+ return m.group(1)
+ return ""
+
+
+def _chargeable_weight_kg(
+ quote: dict[str, Any],
+ options: list[dict[str, Any]],
+ option_no: str,
+ ticket: object,
+) -> str:
+ """
+ 计费重:舱位 > 报价 > 工单事实「计费重」> 毛重。
+ 有正数才返回;否则空串,门禁会补问。
+ """
+ opt = _picked_option(options, option_no)
+ candidates = [
+ opt.get("chargeableWeightKg") or opt.get("chargeable_weight_kg"),
+ quote.get("chargeableWeightKg") or quote.get("chargeable_weight_kg"),
+ ]
+ view = ticket_view(ticket)
+ facts = dict(view.get("facts") or {})
+ candidates.extend(
+ [
+ facts.get("计费重"),
+ facts.get("计费重量"),
+ view.get("chargeableWeightKg"),
+ facts.get("毛重"),
+ view.get("weightKg") or view.get("weight_kg"),
+ ]
+ )
+ for raw in candidates:
+ text = str(raw or "").strip().replace(",", "")
+ if not text or text in {"-", "null", "None"}:
+ continue
+ # 去掉「公斤」「KGS」等后缀,只留数字。
+ num = ""
+ for ch in text:
+ if ch.isdigit() or ch == ".":
+ num += ch
+ elif num:
+ break
+ if not num:
+ continue
+ try:
+ if float(num) > 0:
+ return num
+ except ValueError:
+ continue
+ return ""
+
+
def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]:
raw = quote.get("airOptions") or quote.get("air_options")
if isinstance(raw, str) and raw.strip().startswith("["):
diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py
index e491cb5..7f3c282 100644
--- a/inquiry-agent/agent/policy/air_text_flow.py
+++ b/inquiry-agent/agent/policy/air_text_flow.py
@@ -1780,8 +1780,12 @@ class AirTextInquiryFlow:
extra,
)
from agent.redis_coord.quote_file import mark_quote_file_ready
+ from agent.policy.deal_outcome import ensure_ticket_quoted
mark_quote_file_ready(sess.work_order_no)
+ # 文件已出就必须让主账离开「询价中」,否则成交三态会被六态拒绝。
+ ensure_ticket_quoted(self._ledger, work_order_no=sess.work_order_no)
+ sess.status = sess.status or "已报价"
sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1
@@ -1926,20 +1930,37 @@ class AirTextInquiryFlow:
"""点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。"""
self._grey_clicked_card(reply, card_meta, replace_name=status)
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
+ from agent.policy.sea_group_ops import _quote_fee_rows
+ quote = dict(sess.quote or {})
+ status_now = str(sess.status or "").strip()
+ usable_total = str(quote.get("total") or "").strip()
+ if usable_total in {"", "-"}:
+ usable_total = ""
+ if not (
+ usable_total
+ or _quote_fee_rows(quote)
+ or status_now in {"已报价", "协商中"}
+ ):
+ reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
+ return "deal_need_quote"
inline = harvest_inline_lost_reason(source_text) if status == "未成交" else ""
result = finish_deal_outcome(
ledger=self._ledger,
work_order_no=sess.work_order_no,
outcome=status,
facts=sess.facts,
- quote=sess.quote,
+ quote=quote,
business_line=sess.business_line,
sales_name=sess.invited_sales_name,
lost_reason=inline,
)
if not result.get("ok"):
- reply("主账状态更新失败,成交结果未入账。")
+ err = str(result.get("error") or "")
+ if (not usable_total and not _quote_fee_rows(quote)) or "询价中" in err:
+ reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
+ else:
+ reply("主账状态更新失败,成交结果未入账。")
return "ledger_fail"
phase = str(result.get("phase") or "done")
sess.status = status
@@ -1976,7 +1997,12 @@ class AirTextInquiryFlow:
sess.phase = "done"
sess.allowed = ()
self._save(sess)
- reply(copy.lost_reason_ack(str(result.get("lost_reason") or text)))
+ reply(
+ copy.lost_reason_ack(
+ str(result.get("lost_reason") or text),
+ work_order_no=sess.work_order_no,
+ )
+ )
return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
@@ -1990,8 +2016,10 @@ class AirTextInquiryFlow:
if not no:
return
from agent.redis_coord.quote_file import mark_quote_file_ready
+ from agent.policy.deal_outcome import ensure_ticket_quoted
mark_quote_file_ready(no)
+ ensure_ticket_quoted(self._ledger, work_order_no=no)
if not sender_id:
return
with self._lock:
diff --git a/inquiry-agent/agent/policy/deal_outcome.py b/inquiry-agent/agent/policy/deal_outcome.py
index c93d255..18fc2df 100644
--- a/inquiry-agent/agent/policy/deal_outcome.py
+++ b/inquiry-agent/agent/policy/deal_outcome.py
@@ -210,7 +210,11 @@ def finish_deal_outcome(
writer = getattr(ledger, "transition", None)
if not callable(writer):
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
- result = writer(work_order_no=no, to_status=status, **extra) or {}
+ # 报价文件已出、会话已进入成交跟进,但主账仍停在「询价中」时,
+ # 直接迁「未成交/已成交/协商中」会被六态拒绝。先补「已报价」再迁目标态。
+ result = _transition_deal_status(
+ writer, work_order_no=no, to_status=status, extra=extra, ledger=ledger
+ )
if not result.get("ok"):
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait
@@ -241,6 +245,79 @@ def finish_deal_outcome(
}
+def _transition_deal_status(
+ writer: Callable[..., dict[str, Any]],
+ *,
+ work_order_no: str,
+ to_status: str,
+ extra: dict[str, Any],
+ ledger: Any = None,
+) -> dict[str, Any]:
+ """
+ 写入成交三态。若主账仍停在询价中,先迁已报价再迁目标态。
+
+ 运行证据:
+ - WO022/WO023:文件已发仍停询价中,直迁未成交被六态拒绝;
+ - HTTP 500 时 http_ledger 常只回 http_500(正文里的「询价中」丢失),
+ 不能只靠错误文案判断,要以工单当前状态为准。
+ """
+ no = (work_order_no or "").strip()
+ status = (to_status or "").strip()
+ result = writer(work_order_no=no, to_status=status, **extra) or {}
+ if result.get("ok"):
+ return result
+ if status not in {"已成交", "未成交", "协商中"}:
+ return result
+ err = str(result.get("error") or result.get("message") or "")
+ cur = ""
+ getter = getattr(ledger, "get_for_agent", None) if ledger is not None else None
+ if callable(getter):
+ try:
+ snap = getter(work_order_no=no, sender_id="") or {}
+ cur = str(snap.get("status") or "").strip()
+ except Exception:
+ cur = ""
+ # 状态确认询价中,或错误文案带询价中,或仅有 http_5xx 时仍尝试补已报价。
+ should_promote = (
+ cur == "询价中"
+ or "询价中" in err
+ or err.startswith("http_5")
+ or err in {"transition_failed", "ledger_error"}
+ )
+ if not should_promote:
+ return result
+ promoted = writer(
+ work_order_no=no,
+ to_status="已报价",
+ remark="成交跟进前补已报价",
+ ) or {}
+ if not promoted.get("ok"):
+ return result
+ return writer(work_order_no=no, to_status=status, **extra) or {}
+
+
+def ensure_ticket_quoted(ledger: Any, *, work_order_no: str) -> dict[str, Any]:
+ """
+ 报价文件发出 / 进入成交跟进时,保证主账至少是「已报价」。
+
+ 幂等:已离开「询价中」则不再迁态(已报价→已报价会被六态拒绝)。
+ """
+ no = (work_order_no or "").strip()
+ writer = getattr(ledger, "transition", None)
+ if not no or not callable(writer):
+ return {"ok": False, "error": "no_transition"}
+ getter = getattr(ledger, "get_for_agent", None)
+ if callable(getter):
+ try:
+ snap = getter(work_order_no=no, sender_id="") or {}
+ except Exception:
+ snap = {}
+ st = str(snap.get("status") or "").strip()
+ if st and st != "询价中":
+ return {"ok": True, "status": st, "skipped": True}
+ return writer(work_order_no=no, to_status="已报价", remark="报价文件已出") or {}
+
+
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。@询价小助手 不是原因。"""
text = strip_bot_mentions(reason or "")
diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py
index 471731d..fc2df8f 100644
--- a/inquiry-agent/agent/policy/inquiry_copy.py
+++ b/inquiry-agent/agent/policy/inquiry_copy.py
@@ -1023,7 +1023,12 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
amount = str(item.get("amount") or item.get("value") or "").strip()
line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip()
if name and amount and amount != "-":
- lines.append((name, money_text(amount, line_ccy)))
+ # 燃油附加费率 / 税率是百分比,禁止套成「CNY 1%」。
+ if "%" in amount or name.endswith("费率") or name in {"税率", "燃油附加费率"}:
+ shown = amount if "%" in amount else f"{amount}%"
+ lines.append((name, shown))
+ else:
+ lines.append((name, money_text(amount, line_ccy)))
if lines:
return lines
freight = str(src.get("freight") or "").strip()
@@ -1219,10 +1224,21 @@ def sea_quote_detail_text(
def collab_hint(work_order_no: str) -> str:
- """价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。"""
- return (
- f"若需要咨询内部航线人员,请手动拉新人与智能体到原有群,"
- f"并在群聊发送工单编号{work_order_no}。"
+ """
+ 空运价格卡协同说明:紧挨「不需要协同,直接生成报价单」上方。
+
+ 后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}。
+ 无后台模板时用本默认句(已带工单号)。销售侧示例:
+ …发送“ WO202610060030@询价小助手”,谢谢。
+ """
+ no = (work_order_no or "").strip()
+ return _r(
+ "inquiring.air_collab_hint",
+ (
+ f"若需要咨询内部航线人员,请手动将相关方拉到空运原有协同群,"
+ f"并在空运协同群中发送“ {no}@询价小助手”,谢谢。"
+ ),
+ ticket_no=no,
)
@@ -2661,8 +2677,24 @@ def group_deal_stale(*, work_order_no: str) -> str:
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
-def group_deal_need_quote() -> str:
- return "还没有可确认的报价,请先完成报价再确认成交状态。"
+def group_deal_need_quote(*, work_order_no: str = "") -> str:
+ """
+ 群里确认成交/未成交时,主账还没有可确认报价。
+
+ 空运 WO027:询价中且无 inq_quote,旧句「主账状态更新失败」不说明原因。
+ """
+ no = (work_order_no or "").strip()
+ if no:
+ return (
+ f"工单{no}状态更新失败,原因:没有报价。\n"
+ f"请先报价,然后再更新工单状态。"
+ )
+ return "工单状态更新失败,原因:没有报价。\n请先报价,然后再更新工单状态。"
+
+
+def deal_fail_no_quote(*, work_order_no: str = "") -> str:
+ """成交三态写入失败且判定为没有报价时的销售可见说明。与群门禁同句。"""
+ return group_deal_need_quote(work_order_no=work_order_no)
def group_deal_sales_only() -> str:
@@ -2717,7 +2749,7 @@ def deal_ack(
if outcome == "未成交":
reason = (lost_reason or "").strip()
if reason:
- return f"已记录为「未成交」。{lost_reason_ack(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 "第四个工作日"
return _r(
@@ -2744,14 +2776,19 @@ def _followup_month_day(raw: str) -> str:
return ""
-def lost_reason_ack(reason: str = "") -> str:
- """收下未成交原因后回显原句。后台 lost.reason_ack。"""
+def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str:
+ """
+ 收下未成交原因后回显。后台 lost.reason_ack。
+
+ 测服文案含 {{ticket_no}} / {{lost_reason}};缺 ticket_no 会原样打出占位符。
+ """
text = (reason or "").strip()
+ no = (work_order_no or "").strip()
if text.endswith(("。", ".", "!", "!", "?", "?")):
default = f"已记下未成交原因:{text}"
else:
default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
- return _r("lost.reason_ack", default, lost_reason=text)
+ return _r("lost.reason_ack", default, lost_reason=text, ticket_no=no)
def deal_notify_card(
@@ -3044,6 +3081,53 @@ def air_ask_lock_option(options: list) -> str:
return "\n".join(lines)
+def air_ask_manual_lock_fields(
+ *,
+ need_option: bool = False,
+ need_airline: bool = False,
+ need_chargeable: bool = False,
+ need_flight: bool = False,
+ options: list | None = None,
+) -> str:
+ """
+ 手动报价锁舱缺 TMS MANUAL 必填项时,群里补问,禁止空壳出站。
+
+ 例:请回复「AIR-OPT01 CZ 航班CZ327」。
+ """
+ bits: list[str] = []
+ if need_option:
+ bits.append("舱位编号(如 AIR-OPT01)")
+ if need_airline:
+ bits.append("航司两位码(如 CZ)")
+ if need_flight:
+ bits.append("航班号(如 CZ327)")
+ if need_chargeable:
+ bits.append("计费重(公斤)")
+ need = "、".join(bits) if bits else "舱位编号、航司和航班号"
+ lines = [
+ f"锁舱还缺:{need}。",
+ "请按「AIR-OPT01 CZ 航班CZ327」回复;",
+ ]
+ if need_chargeable:
+ lines.append("若还缺计费重,请再写「计费重xx公斤」。")
+ else:
+ lines.append("例如:AIR-OPT01 CZ 航班CZ327")
+ opts = options or []
+ shown = False
+ for item in opts:
+ if not isinstance(item, dict):
+ continue
+ no = str(item.get("optionNo") or item.get("option_no") or "").strip()
+ airline = str(item.get("airlineCode") or item.get("airlineName") or "").strip()
+ if not no:
+ continue
+ if not shown:
+ lines.append("可选舱位:")
+ shown = True
+ lines.append(f"{no} {airline}".strip() if airline else no)
+ return "\n".join(lines)
+
+
def air_lock_ok(
*,
work_order_no: str,
@@ -3072,7 +3156,7 @@ def air_lock_ok(
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
- """空运锁舱失败。后台 inquiring.air_lock_fail。"""
+ """空运锁舱失败(接口/系统类)。后台 inquiring.air_lock_fail。"""
why = (reason or "").strip() or "TMS未返回原因"
default = (
f"工单{work_order_no} 空运锁舱失败。\n"
@@ -3089,6 +3173,25 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
)
+def air_lock_incomplete(*, work_order_no: str, operator: str, reason: str = "") -> str:
+ """
+ 锁舱参数不齐(纯手工价、运价库无舱位等):业务补问,不算系统异常,不惊动 IT。
+
+ 航线按示例回舱位+航司+航班后即可再锁。
+ """
+ why = (reason or "").strip()
+ lines = [
+ f"工单{work_order_no} 还不能锁舱。",
+ f"操作人:{operator}",
+ ]
+ if why:
+ lines.append(f"原因:{why}")
+ else:
+ lines.append("原因:本单没有可引用的 TMS 舱位(手工价或运价库无匹配)。")
+ lines.append("请直接回复「AIR-OPT01 CZ 航班CZ327」(换成实际舱位、航司、航班号)后再锁。")
+ return "\n".join(lines)
+
+
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
"""
锁舱/释放失败原因:原样回 TMS 的 failReason。
diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py
index e71b376..fda9640 100644
--- a/inquiry-agent/agent/policy/sea_group_ops.py
+++ b/inquiry-agent/agent/policy/sea_group_ops.py
@@ -906,12 +906,14 @@ def apply_product_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"
- if _quote_fee_rows(base):
+ # 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
+ if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
quote = merge_quote_fees(base, quote)
_save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket
@@ -1231,8 +1233,11 @@ def apply_group_deal(
return "not_sales"
quote = _current_quote(view, sess)
status_now = ticket_status(ticket) or (sess.status if sess else "")
- if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
- send_group_text(client, chat_id, copy.group_deal_need_quote())
+ usable_total = str(quote.get("total") or "").strip()
+ if usable_total in {"", "-"}:
+ usable_total = ""
+ if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
+ send_group_text(client, chat_id, copy.group_deal_need_quote(work_order_no=no))
return "deal_need_quote"
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
@@ -1309,7 +1314,14 @@ def capture_group_lost_reason(
sess.phase = "done"
sess.allowed = ()
flow._save(sess)
- send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text)))
+ send_group_text(
+ client,
+ chat_id,
+ copy.lost_reason_ack(
+ str(result.get("lost_reason") or text),
+ work_order_no=no,
+ ),
+ )
return "done"
@@ -1634,6 +1646,53 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
return stamp_prices_for_sheet(out)
+def _attach_land_weight_band_segment(
+ quote: dict[str, Any], view: dict[str, Any]
+) -> dict[str, Any]:
+ """
+ 陆运产品分档价:把 weightPrices / 燃油税率挂到 landSegments,出单才能填「2-500KG」列。
+
+ 无分档价时原样返回。OD 取工单询价事实,不另猜城市。
+ """
+ src = dict(quote or {})
+ tiers = src.get("weightPrices")
+ if not isinstance(tiers, list) or not tiers:
+ return src
+ facts = dict(view.get("facts") or {})
+ origin = str(
+ facts.get("始发地")
+ or facts.get("起运地")
+ or facts.get("origin")
+ or view.get("origin")
+ or ""
+ ).strip()
+ dest = str(
+ facts.get("目的地")
+ or facts.get("destination")
+ or view.get("destination")
+ or ""
+ ).strip()
+ seg: dict[str, Any] = {
+ "origin": origin,
+ "destination": dest,
+ "weightPrices": list(tiers),
+ "currency": str(src.get("currency") or "CNY"),
+ "salePrice": str(src.get("total") or tiers[0].get("price") or ""),
+ "price_per_kg": str(src.get("price_per_kg") or tiers[0].get("price") or ""),
+ }
+ fuel = str(src.get("fuel_surcharge_rate") or src.get("fuelSurchargeRate") or "").strip()
+ tax = str(src.get("tax_rate") or src.get("taxRate") or "").strip()
+ if fuel:
+ seg["fuel_surcharge_rate"] = fuel
+ seg["fuelSurchargeRate"] = fuel
+ if tax:
+ seg["tax_rate"] = tax
+ seg["taxRate"] = tax
+ src["landSegments"] = [seg]
+ src["route_rows"] = [seg]
+ return src
+
+
def _save_product_quote(
flow: Any,
no: str,
diff --git a/inquiry-agent/agent/policy/system_exception.py b/inquiry-agent/agent/policy/system_exception.py
index 4a53cf2..735a257 100644
--- a/inquiry-agent/agent/policy/system_exception.py
+++ b/inquiry-agent/agent/policy/system_exception.py
@@ -850,6 +850,7 @@ def _resume_cabin_step(
work_order_no=no,
lock_id=lock_id,
sender_id=sender,
+ operator_name=str(extra.get("operator_name") or "").strip(),
instruction_text=instruction,
)
or {}
@@ -860,13 +861,20 @@ def _resume_cabin_step(
service = "空运舱位"
reason = "空运释放舱位接口调用失败"
else:
+ # 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。
locker = getattr(ledger, "lock_cabin", None)
+ op_name = str(extra.get("operator_name") or "").strip()
+ airline = str(extra.get("airline") or "").strip()
+ flight_no = str(extra.get("flight_no") or "").strip()
out = call_with_retries(
lambda: locker(
work_order_no=no,
option_no=option_no,
sender_id=sender,
+ operator_name=op_name,
instruction_text=instruction,
+ airline=airline,
+ flight_no=flight_no,
)
or {}
if callable(locker)
diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py
index 8db5c8e..cf28d75 100644
--- a/inquiry-agent/agent/routing/air_group_intent.py
+++ b/inquiry-agent/agent/routing/air_group_intent.py
@@ -154,10 +154,17 @@ def _hint_lock_or_release(text: str) -> str:
def _only_work_order(text: str) -> bool:
- """去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。"""
+ """
+ 去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。
+
+ 销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。
+ 运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。
+ """
no = copy.extract_work_order_no(text)
rest = (text or "").replace(no, "").replace(no.lower(), "")
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
+ for noise in ("工单编号", "工单号", "编号"):
+ rest = rest.replace(noise, "")
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
return not rest.strip()
diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py
index 6625c9e..51ec3a7 100644
--- a/inquiry-agent/tests/test_air_group_collab.py
+++ b/inquiry-agent/tests/test_air_group_collab.py
@@ -421,6 +421,21 @@ class AirGroupCollabTests(unittest.TestCase):
classify_air_group_text("@询价小助手 WO202609160002", allow_network=False),
INTENT_ACTIVATE,
)
+ # 话术前缀「工单编号」不能当成报价(WO026 复现:误走 product_quote)。
+ self.assertEqual(
+ classify_air_group_text(
+ "工单编号WO202610070026@询价小助手",
+ allow_network=False,
+ ),
+ INTENT_ACTIVATE,
+ )
+ self.assertEqual(
+ classify_air_group_text(
+ "工单编号 WO202610060026@询价小助手",
+ allow_network=False,
+ ),
+ INTENT_ACTIVATE,
+ )
from unittest.mock import patch
with patch(
@@ -434,6 +449,13 @@ class AirGroupCollabTests(unittest.TestCase):
),
INTENT_ACTIVATE,
)
+ self.assertEqual(
+ classify_air_group_text(
+ "工单编号WO202610070026@询价小助手",
+ allow_network=True,
+ ),
+ INTENT_ACTIVATE,
+ )
def test_dispatch_aibot_goes_air(self) -> None:
from agent.routing.dispatch import dispatch_inbound as _disp
@@ -906,7 +928,8 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
self.assertFalse(any("执行成功" in t for t in self._texts()))
- def test_manual_quote_without_quote_id_can_lock(self) -> None:
+ def test_manual_quote_without_option_asks_fields(self) -> None:
+ """纯人工价且口令无 AIR-OPT:交主账后仍缺 → lock_incomplete(不空壳成功)。"""
wo = self._create_air(
quote={
"total": "100",
@@ -921,6 +944,33 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
+ self.assertEqual(phase, "lock_incomplete")
+ joined = "\n".join(self._texts())
+ self.assertIn("舱位编号", joined)
+ self.assertFalse(any("执行成功" in t for t in self._texts()))
+
+ def test_manual_quote_with_option_airline_can_lock(self) -> None:
+ """人工价补齐舱位+航司+航班后可锁;计费重用工单毛重兜底。"""
+ wo = self._create_air(
+ quote={
+ "total": "100",
+ "source": "manual",
+ "source_label": "产品报价",
+ "currency": "CNY",
+ },
+ status="已报价",
+ )
+ phase = handle_air_group(
+ self._msg(
+ sender="air1",
+ text=f"{wo}锁舱,锁AIR-OPT01 CZ 航班CZ327",
+ chat="wr_air_lock_manual2",
+ mid="lm2",
+ ),
+ flow=self.flow,
+ ledger=self.ledger,
+ injected_intent="air_lock",
+ )
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
@@ -1348,7 +1398,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
- wo = self._create_air(quote={}, status="询价中")
+ wo = self._create_air()
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py
index 18fbd8d..d601b9c 100644
--- a/inquiry-agent/tests/test_deal_outcome.py
+++ b/inquiry-agent/tests/test_deal_outcome.py
@@ -144,10 +144,75 @@ class TestDealOutcome(unittest.TestCase):
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "done")
+ def test_lost_from_inquiring_promotes_quoted_first(self) -> None:
+ """主账仍询价中时点未成交:先补已报价再入未成交,禁止直接失败。"""
+
+ class _StrictSix:
+ def __init__(self) -> None:
+ self.status = "询价中"
+ self.lost_reason = ""
+ self.event_wait_kind = ""
+
+ def transition(self, *, work_order_no, to_status, **kwargs):
+ _ = work_order_no
+ allowed = {
+ "询价中": {"已报价", "协商中", "已关闭"},
+ "已报价": {"协商中", "已成交", "未成交", "已关闭"},
+ }
+ if to_status not in allowed.get(self.status, set()):
+ # 模拟 http_ledger 在 500 时只回 http_500(丢失正文)
+ return {"ok": False, "error": "http_500"}
+ self.status = to_status
+ if to_status == "未成交" and kwargs.get("lost_reason"):
+ self.lost_reason = str(kwargs.get("lost_reason") or "")
+ return {"ok": True, "status": self.status}
+
+ def patch_lost_reason(self, *, work_order_no, lost_reason):
+ _ = work_order_no
+ self.lost_reason = lost_reason
+ return {"ok": True}
+
+ def get_for_agent(self, *, work_order_no, sender_id):
+ _ = (work_order_no, sender_id)
+ return {"status": self.status, "lost_reason": self.lost_reason}
+
+ ledger = _StrictSix()
+ out = finish_deal_outcome(
+ ledger=ledger,
+ work_order_no="WO202610070023",
+ outcome="未成交",
+ facts={},
+ quote={},
+ business_line="AIR",
+ )
+ self.assertTrue(out.get("ok"), out)
+ self.assertEqual(ledger.status, "未成交")
+ self.assertEqual(out.get("phase"), "wait_lost_reason")
+
def test_lost_reason_ack_repeats_user_text(self) -> None:
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
+ def test_lost_reason_ack_fills_ticket_no_from_bot_reply(self) -> None:
+ """后台文案带 {{ticket_no}} 时必须填实值,禁止原样打出占位符。"""
+ from agent.policy import bot_reply
+
+ bot_reply.override_catalog(
+ {
+ "lost.reason_ack": (
+ "已将未成交原因“{{lost_reason}}”记录在后台,"
+ "工单{{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。"
+ )
+ }
+ )
+ try:
+ text = copy.lost_reason_ack("换时间", work_order_no="WO202610070018")
+ self.assertIn("WO202610070018", text)
+ self.assertNotIn("{{ticket_no}}", text)
+ self.assertIn("换时间", text)
+ finally:
+ bot_reply.override_catalog(None)
+
def test_harvest_inline_lost_reason(self) -> None:
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
self.assertEqual(
diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py
index b1a6a4f..6fd1282 100644
--- a/inquiry-agent/tests/test_sea_text_inquiry.py
+++ b/inquiry-agent/tests/test_sea_text_inquiry.py
@@ -1341,6 +1341,28 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
+ def test_land_weight_band_unit_price_and_rates(self) -> None:
+ """陆运集拼:2-500KG 单价 + 燃油附加费率/税率,不能把档下限 2 当成燃油附加费。"""
+ from agent.llm.extract_text import extract_product_quote
+
+ quote = extract_product_quote(
+ "2-500KG 单价CNY990,燃油附加费率1%,税率6%"
+ )
+ self.assertEqual(str(quote.get("total") or ""), "990")
+ self.assertEqual(str(quote.get("fuel_surcharge_rate") or ""), "1%")
+ self.assertEqual(str(quote.get("tax_rate") or ""), "6%")
+ tiers = list(quote.get("weightPrices") or [])
+ self.assertEqual(len(tiers), 1)
+ self.assertEqual(int(tiers[0].get("min") or 0), 2)
+ self.assertEqual(int(tiers[0].get("max") or 0), 500)
+ self.assertEqual(str(tiers[0].get("price") or ""), "990")
+ rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
+ self.assertIn("2-500KG", rows)
+ self.assertEqual(str(rows["2-500KG"].get("amount") or ""), "990")
+ self.assertNotEqual(str(rows.get("燃油附加费", {}).get("amount") or ""), "2")
+ self.assertEqual(str(rows.get("燃油附加费率", {}).get("amount") or ""), "1%")
+ self.assertEqual(str(rows.get("税率", {}).get("amount") or ""), "6%")
+
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
from agent.llm.extract_text import extract_product_quote
diff --git a/inquiry-agent/tests/test_system_exception.py b/inquiry-agent/tests/test_system_exception.py
index be14d8a..54bda3a 100644
--- a/inquiry-agent/tests/test_system_exception.py
+++ b/inquiry-agent/tests/test_system_exception.py
@@ -477,22 +477,45 @@ class TestConfirmSystemException(unittest.TestCase):
self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined)
def test_admin_retry_cabin_lock_runs_lock(self) -> None:
+ # 空运单 + 可锁报价;重试必须带上 operator_name / airline。
+ air = self.ledger.create_ticket(
+ sender_id="sales1",
+ business_line="AIR",
+ facts={"起运地": "上海", "目的地": "洛杉矶", "毛重": "100"},
+ )
+ air_wo = str(air.get("work_order_no") or "")
+ self.ledger.upsert_quote(
+ work_order_no=air_wo,
+ quote={
+ "total": "800",
+ "source": "TMS",
+ "quoteId": "QT-AIR-1",
+ "airOptions": [{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}],
+ },
+ to_status="已报价",
+ )
self.ledger.mark_system_exception(
- work_order_no=self.wo,
+ work_order_no=air_wo,
system_exception=TYPE_TMS,
system_exception_reason="空运锁舱接口调用失败",
step=STEP_TMS_CABIN,
payload={
"conversation_kind": "group",
"conversation_target": "chat-air",
- "extra": {"option_no": "AIR-OPT-01", "instruction": "锁舱"},
+ "extra": {
+ "option_no": "AIR-OPT-01",
+ "instruction": "锁舱",
+ "operator_name": "HangXian",
+ "airline": "CZ",
+ },
},
)
- hits = {"n": 0}
+ hits = {"n": 0, "kwargs": {}}
orig_lock = self.ledger.lock_cabin
def counted(**kwargs):
hits["n"] += 1
+ hits["kwargs"] = dict(kwargs)
return orig_lock(**kwargs)
self.ledger.lock_cabin = counted # type: ignore[method-assign]
@@ -504,12 +527,14 @@ class TestConfirmSystemException(unittest.TestCase):
orig = se._send_to_conversation
se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or ""))
try:
- out = run_admin_retry(self.wo, ledger=self.ledger)
+ out = run_admin_retry(air_wo, ledger=self.ledger)
finally:
se._send_to_conversation = orig
self.assertTrue(out.get("ok"))
self.assertGreaterEqual(hits["n"], 1)
- ticket = self.ledger.get_ticket(work_order_no=self.wo)
+ self.assertEqual(hits["kwargs"].get("operator_name"), "HangXian")
+ self.assertEqual(hits["kwargs"].get("airline"), "CZ")
+ ticket = self.ledger.get_ticket(work_order_no=air_wo)
self.assertFalse(ticket_is_paused(ticket))
self.assertTrue(any("空运舱位接口已恢复" in x for x in talks))
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java
index 12f53b7..d6649a7 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java
@@ -1171,6 +1171,27 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 0
+ && abs.compareTo(java.math.BigDecimal.ONE) <= 0) {
+ n = n.multiply(new java.math.BigDecimal("100")).stripTrailingZeros();
+ }
+ return n.stripTrailingZeros().toPlainString() + "%";
+ } catch (RuntimeException ex) {
+ return text;
+ }
+ }
+
+ /** rateFlag:1=含税,0=不含税。 */
+ static String formatLandTaxIncludedDisplay(String raw) {
+ if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
+ return "";
+ }
+ String text = raw.trim();
+ if ("1".equals(text) || "true".equalsIgnoreCase(text) || "是".equals(text) || "含税".equals(text)) {
+ return "含税";
+ }
+ if ("0".equals(text) || "false".equalsIgnoreCase(text) || "否".equals(text) || "不含税".equals(text)) {
+ return "不含税";
+ }
+ return text;
+ }
+
private static void writeFeeRows(Workbook wb, JSONObject mapping, JSONObject facts, Set yellow) {
JSONObject feeRows = mapping.getJSONObject("fee_rows");
if (feeRows == null) {
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java
index d902bc3..60be325 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java
@@ -75,11 +75,16 @@ public class InquiryTmsCabinService {
Map quote = payloadOf(rows.get(0));
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
- if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
+ // 库里只有人工价、没有舱位时也要现拉 TMS:补 quoteId/airOptions,
+ // 避免 MANUAL 空壳 1001,也避免逼航线手打航班号。
+ boolean cabinSparse = StringUtils.isBlank(resolveQuoteId(quote, no))
+ || airOptions(quote).isEmpty();
+ if (cabinSparse) {
quote = refreshLockQuote(ticket, quote);
}
String tmsQuoteId = realTmsQuoteId(rows, no);
- if (!latestManual && StringUtils.isBlank(tmsQuoteId)) {
+ // 现拉到的 TMS 编号也算:库里无历史行时仍可 MANUAL+quoteId。
+ if (StringUtils.isBlank(tmsQuoteId)) {
tmsQuoteId = resolveQuoteId(quote, no);
}
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
@@ -140,26 +145,68 @@ public class InquiryTmsCabinService {
text(option, "destination"), ticket.getDestination(),
fact(ticket, "目的地", "目的港")));
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
- req.put("airline", firstNonBlank(
+ // airlineName 中文名不能当航司码出站,只取两位码或航班前缀。
+ String airlineCode = firstNonBlank(
text(body, "airline", "airlineCode"),
airlineFromInstruction(text(body, "instructionText", "instruction_text")),
text(option, "airlineCode", "airline"),
- text(option, "airlineName"),
- text(quote, "airlineCode", "airline", "airlineName"),
- fact(ticket, "航司", "航司代码"),
- airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo")))));
+ text(quote, "airlineCode", "airline"),
+ fact(ticket, "航司代码"),
+ airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"))),
+ airlineCodeOrBlank(text(option, "airlineName")),
+ airlineCodeOrBlank(text(quote, "airlineName")),
+ airlineCodeOrBlank(fact(ticket, "航司")));
+ req.put("airline", airlineCode);
req.put("weightKg", ticket.getWeightKg());
req.put("volumeCbm", ticket.getVolumeCbm());
req.put("pieceQty", ticket.getPieces());
+ // 计费重:舱位 > 报价 > 工单计费重事实 > 毛重(避免 MANUAL 空壳 1001)。
req.put("chargeableWeightKg", firstNonBlank(
text(option, "chargeableWeightKg", "chargeable_weight_kg"),
- text(quote, "chargeableWeightKg", "chargeable_weight_kg")));
+ text(quote, "chargeableWeightKg", "chargeable_weight_kg"),
+ fact(ticket, "计费重", "计费重量"),
+ ticket.getWeightKg() == null ? "" : String.valueOf(ticket.getWeightKg()),
+ fact(ticket, "毛重")));
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
- req.put("operatorName", text(body, "operatorName", "operator_name"));
+ // 重试漏传姓名时用 userid 兜底,仍要有 operatorName 键。
+ req.put("operatorName", firstNonBlank(
+ text(body, "operatorName", "operator_name"),
+ text(body, "operatorUserId", "sender_id")));
req.put("instructionText", text(body, "instructionText", "instruction_text"));
if ("MANUAL".equals(lockSource)) {
// 有 TMS 时保留 quoteId;纯人工才去掉。
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
+ // 人工价常不写有效期:用航班日当天 23:59:59+08:00 兜底,避免空壳 1001。
+ if (StringUtils.isBlank(text(req, "validUntil"))) {
+ String isoUntil = normalizeValidUntilIso(text(req, "flightDate"));
+ if (StringUtils.isNotBlank(isoUntil)) {
+ req.put("validUntil", isoUntil);
+ if (StringUtils.isBlank(text(req, "requestedUntil"))) {
+ req.put("requestedUntil", isoUntil);
+ }
+ }
+ }
+ // 缺必填仍出站会被 TMS 1001;在此拦住并回清原因。
+ String incomplete = validateManualLockOutbound(req);
+ // 现拉仍无舱位时,改成业务补问口径(禁止堆「缺少 optionNo」技术字段名)。
+ if (StringUtils.isNotBlank(incomplete)) {
+ String refreshClass = text(quote, "_lockRefreshClass");
+ String refreshMsg = text(quote, "_lockRefreshMsg");
+ boolean noMatch = "TMS_NO_QUOTE_1002".equals(refreshClass)
+ || (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配"));
+ if (noMatch) {
+ incomplete = "本单是手工报价,且运价库无匹配舱位,无法自动带出航班。"
+ + "请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。"
+ + "(现拉TMS:" + firstNonBlank(refreshMsg, "标准运价库无匹配报价") + ")";
+ } else if (StringUtils.isNotBlank(refreshMsg)) {
+ incomplete = incomplete + "(现拉TMS:" + refreshMsg + ")";
+ }
+ }
+ if (StringUtils.isNotBlank(incomplete)) {
+ log.info("tms.lock incomplete wo={} optionNo={} missing={}",
+ no, resolvedOption, incomplete);
+ return fail("lock_incomplete", incomplete);
+ }
} else {
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
req.put("source", "SYSTEM");
@@ -315,14 +362,26 @@ public class InquiryTmsCabinService {
}
Map body = new LinkedHashMap<>();
body.put("workOrderNo", ticket.getWorkOrderNo());
+ // 锁舱现拉必须用「今天」报价日:历史询价日常已无运价(1002),
+ // 不能把销售初询那天的日期带出去导致永远补不到舱位。
+ JSONObject facts = new JSONObject();
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
- JSONObject facts = JSON.parseObject(ticket.getFactsJson());
- if (facts != null) {
- body.put("facts", facts);
+ JSONObject stored = JSON.parseObject(ticket.getFactsJson());
+ if (stored != null) {
+ facts.putAll(stored);
}
}
+ SimpleDateFormat dayFmt = new SimpleDateFormat("yyyy-MM-dd");
+ dayFmt.setTimeZone(java.util.TimeZone.getTimeZone("Asia/Shanghai"));
+ String today = dayFmt.format(new java.util.Date());
+ facts.put("报价日期", today);
+ body.put("facts", facts);
try {
Map live = queryService.queryFromAgent(body);
+ if (live != null && live.get("classification") != null) {
+ current.put("_lockRefreshClass", String.valueOf(live.get("classification")));
+ current.put("_lockRefreshMsg", String.valueOf(live.get("message") == null ? "" : live.get("message")));
+ }
Object raw = live == null ? null : live.get("quote");
if (!(raw instanceof Map)) {
return current;
@@ -335,14 +394,19 @@ public class InquiryTmsCabinService {
if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) {
current.put("airOptions", fetched.get("airOptions"));
}
+ if (airOptions(current).isEmpty() && fetched.get("air_options") != null) {
+ current.put("airOptions", fetched.get("air_options"));
+ }
if (StringUtils.isBlank(text(current, "chargeableWeightKg", "chargeable_weight_kg"))
&& StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) {
current.put("chargeableWeightKg", text(fetched, "chargeableWeightKg", "chargeable_weight_kg"));
}
- log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={}",
+ log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={} class={} quoteDate={}",
ticket.getWorkOrderNo(),
StringUtils.isNotBlank(text(current, "quoteId", "quote_id")),
- airOptions(current).size());
+ airOptions(current).size(),
+ live == null ? "" : live.get("classification"),
+ today);
} catch (RuntimeException ex) {
log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
}
@@ -416,7 +480,11 @@ public class InquiryTmsCabinService {
if (StringUtils.isNotBlank(transfer)) {
req.put("transferAirport", transfer);
}
- String flightNo = firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"));
+ // 句中/请求体点名的航班号优先,其次舱位与报价。
+ String flightNo = firstNonBlank(
+ text(body, "flightNo", "flight_no"),
+ text(option, "flightNo", "flight_no"),
+ text(quote, "flightNo", "flight_no"));
if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo);
}
@@ -463,11 +531,41 @@ public class InquiryTmsCabinService {
List
+ *
+ * 为何加护栏:询价测服库菜单已是 Vue3,且没有 {@code sys_permission_v3};盲目切换会弄挂后台菜单。
+ *
+ */
@Override
public void switchVue3Menu() {
+ Long vue3TableCnt = sysPermissionMapper.countVue3MenuTable();
+ if (vue3TableCnt == null || vue3TableCnt.longValue() <= 0L) {
+ throw new JeecgBootException("当前库没有 sys_permission_v3,菜单已是 Vue3 或不可自动切换,已拒绝执行");
+ }
sysPermissionMapper.backupVue2Menu();
sysPermissionMapper.changeVue3Menu();
}
diff --git a/inquiry-backend/src/store/modules/permission.ts b/inquiry-backend/src/store/modules/permission.ts
index 2c3d5d7..a8655cc 100644
--- a/inquiry-backend/src/store/modules/permission.ts
+++ b/inquiry-backend/src/store/modules/permission.ts
@@ -2,7 +2,6 @@ import type { AppRouteRecordRaw, Menu } from '/@/router/types';
import { defineStore } from 'pinia';
import { store } from '/@/store';
-import { useI18n } from '/@/hooks/web/useI18n';
import { useUserStore } from './user';
import { useAppStoreWithOut } from './app';
import { toRaw } from 'vue';
@@ -18,10 +17,9 @@ import { ERROR_LOG_ROUTE, PAGE_NOT_FOUND_ROUTE } from '/@/router/routes/basic';
import { filter } from '/@/utils/helper/treeHelper';
-import { getMenuList,switchVue3Menu } from '/@/api/sys/menu';
+import { getMenuList } from '/@/api/sys/menu';
import { getPermCode } from '/@/api/sys/user';
-import { useMessage } from '/@/hooks/web/useMessage';
import { PageEnum } from '/@/enums/pageEnum';
/**
@@ -246,7 +244,6 @@ export const usePermissionStore = defineStore({
this.setAuthData(systemPermission);
},
async buildRoutesAction(): Promise {
- const { t } = useI18n();
const userStore = useUserStore();
const appStore = useAppStoreWithOut();
@@ -322,54 +319,18 @@ export const usePermissionStore = defineStore({
// 后台菜单构建
case PermissionModeEnum.BACK:
- const { createMessage, createWarningModal } = useMessage();
- // 菜单加载提示
- // createMessage.loading({
- // content: t('sys.app.menuLoading'),
- // duration: 1,
- // });
-
// 从后台获取权限码,
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
let routeList: AppRouteRecordRaw[] = [];
try {
this.changePermissionCode();
routeList = (await getMenuList()) as AppRouteRecordRaw[];
- // update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
- let hasIndex: boolean = false;
- let hasIcon: boolean = false;
- for (let menuItem of routeList) {
- // 条件1:判断组件是否是 layouts/default/index
- if (!hasIndex) {
- hasIndex = menuItem.component === 'layouts/default/index';
- }
- // 条件2:判断图标是否带有 冒号
- if (!hasIcon) {
- hasIcon = !!menuItem.meta?.icon?.includes(':');
- }
- // 满足任何一个条件都直接跳出循环
- if (hasIcon || hasIndex) {
- break;
- }
- }
- // 两个条件都不满足,就弹出提示框
- if (!hasIcon && !hasIndex) {
- // 延迟1.5秒之后再出现提示,否则提示框出不来
- setTimeout(
- () =>
- createWarningModal({
- title: '检测提示',
- content:
- '当前菜单表是 Vue2版本,导致菜单加载异常!
点击确认,切换到Vue3版菜单!',
- onOk:function () {
- switchVue3Menu();
- location.reload();
- }
- }),
- 100
- );
- }
- // update-end----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
+ // Jeecg 原逻辑:顶层没有 layouts/default/index 且图标无冒号 → 弹窗并调用 /sys/switchVue3Menu
+ // (把 sys_permission rename 成 v2,再把 sys_permission_v3 rename 成正式表)。
+ // 询价库 inquiry_robot 菜单种子已是 Vue3,且测服没有 sys_permission_v3;点确认会弄挂侧栏。
+ // 因此禁用自动切表弹窗;后面仍会 ensureInquiryHandwrittenRoutes 补齐询价页。
+ // update-begin----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
+ // update-end----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
} catch (error) {
console.error(error);
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
diff --git a/inquiry-backend/src/views/inquiry/account/index.vue b/inquiry-backend/src/views/inquiry/account/index.vue
index 63afe51..daf0fb1 100644
--- a/inquiry-backend/src/views/inquiry/account/index.vue
+++ b/inquiry-backend/src/views/inquiry/account/index.vue
@@ -325,6 +325,7 @@ function removeAccount(row: AccountRow) {
账号 |
姓名 |
绑定角色 |
+ 数据范围 |
手机号 |
最后登录 |
状态 |
@@ -333,10 +334,10 @@ function removeAccount(row: AccountRow) {
- | 加载中… |
+ 加载中… |
- | 暂无账号 |
+ 暂无账号 |
| {{ row.username }} |
@@ -344,6 +345,20 @@ function removeAccount(row: AccountRow) {
{{ row.roleLabel }}
|
+
+
+
+ {{ row.dashboardSelfOnly ? "只看自己数据" : "全部数据" }}
+
+ |
{{ row.phoneMasked }} |
{{ row.lastLogin }} |
diff --git a/inquiry-backend/src/views/inquiry/login/index.vue b/inquiry-backend/src/views/inquiry/login/index.vue
index 7a85070..5c81a3d 100644
--- a/inquiry-backend/src/views/inquiry/login/index.vue
+++ b/inquiry-backend/src/views/inquiry/login/index.vue
@@ -2,6 +2,9 @@
/**
* 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。
* 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。
+ *
+ * 禁止写死 admin/123456:退出后再进登录页会误显示测试账号。
+ * 仅记住上次成功登录的账号(不存密码),方便换人登录时改账号。
*/
import { reactive, ref, toRaw } from 'vue';
import { ConfigProvider, message } from 'ant-design-vue';
@@ -10,11 +13,29 @@
import '/@/views/inquiry/styles/admin-prototype.css';
import '/@/views/inquiry/styles/admin-fidelity.css';
+ const LAST_USERNAME_KEY = 'inquiry_admin_last_username';
+
+ function readLastUsername(): string {
+ try {
+ return String(localStorage.getItem(LAST_USERNAME_KEY) || '').trim();
+ } catch {
+ return '';
+ }
+ }
+
+ function rememberUsername(username: string) {
+ try {
+ localStorage.setItem(LAST_USERNAME_KEY, username);
+ } catch {
+ /* 隐私模式等写失败时忽略 */
+ }
+ }
+
const userStore = useUserStore();
const loading = ref(false);
const form = reactive({
- username: 'admin',
- password: '123456',
+ username: readLastUsername(),
+ password: '',
});
const themeToken = {
@@ -36,9 +57,10 @@
}
loading.value = true;
try {
+ const username = form.username.trim();
const { userInfo } = await userStore.login(
toRaw({
- username: form.username.trim(),
+ username,
password: form.password,
// 主账已关验证码;仍传空字段以兼容 LoginParams 类型
captcha: '',
@@ -48,6 +70,9 @@
})
);
if (userInfo) {
+ rememberUsername(username);
+ // 成功后清空密码,避免浏览器后退/再进登录页时残留明文
+ form.password = '';
message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`);
}
} catch (e: any) {
diff --git a/inquiry-backend/src/views/inquiry/mock/botReplies.ts b/inquiry-backend/src/views/inquiry/mock/botReplies.ts
index 36b64d9..ba44e79 100644
--- a/inquiry-backend/src/views/inquiry/mock/botReplies.ts
+++ b/inquiry-backend/src/views/inquiry/mock/botReplies.ts
@@ -342,7 +342,7 @@ export const mockReplyCategories: ReplyCategory[] = [
id: "brv10000000000000000000000000041",
replyCode: "lost.reason_ack",
trigger: "销售回复未成交原因",
- text: "已记下未成交原因:{{lost_reason}}。",
+ text: "已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。",
enabled: true,
}
],
diff --git a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css
index 4de51f6..39c5cfb 100644
--- a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css
+++ b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css
@@ -23,13 +23,16 @@ body {
grid-template-columns: 220px minmax(0, 1fr) !important;
width: 100%;
max-width: 100%;
- overflow-x: hidden;
+ /* 锁视口:避免 html overflow:hidden 裁切后整页既无文档滚、内容区又不内滚 */
+ height: 100vh !important;
+ max-height: 100vh !important;
+ min-height: 0 !important;
+ overflow: hidden !important;
}
.main-stage {
background: #f7f8fb !important;
min-width: 0 !important;
- overflow-x: hidden;
/* 主区锁视口高度:列表页表格区内滚;看板等非列表页在 .admin-content 内滚 */
height: 100vh !important;
max-height: 100vh !important;
@@ -43,10 +46,16 @@ body {
padding: 16px 16px 40px !important;
gap: 12px !important;
min-width: 0 !important;
- overflow-x: hidden;
- flex: 1 1 auto !important;
+ /*
+ * flex-basis:0:先按剩余视口占高,再在区内滚动。
+ * 若用 1 1 auto,内容区会先被看板撑满,再被 .main-stage 裁掉 → 右侧无滚动条、滚轮无效。
+ */
+ flex: 1 1 0 !important;
min-height: 0 !important;
- overflow: auto !important;
+ height: 0 !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
+ -webkit-overflow-scrolling: touch;
}
/*
@@ -1070,6 +1079,9 @@ h6 {
.admin-content:has(.ref-surface--metrics) {
padding: 14px 14px 36px !important;
gap: 10px !important;
+ /* 分析看板:明确在内容区竖滚,避免被列表页 overflow:hidden 规则误伤 */
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
}
/* ---- 话术 ---- */
diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue
index f717df2..4595315 100644
--- a/inquiry-backend/src/views/inquiry/workOrder/index.vue
+++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue
@@ -253,9 +253,88 @@ function parseQuoteFees(q: any): TicketQuoteFee[] {
pushRows(asFeeArray(payload[key] || payload[bucket]));
}
}
+ // 陆运 TMS:燃油附加费率 / 税率挂在线路段上,不是金额费目行。
+ for (const extra of parseLandRateFees(payload)) {
+ if (!out.some((x) => x.name === extra.name && x.amount === extra.amount)) {
+ out.push(extra);
+ }
+ }
return out;
}
+/**
+ * 陆运线路:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。
+ * 只读 payload,不写库。海运/空运没有这些键则不出行。
+ */
+function parseLandRateFees(payload: Record): TicketQuoteFee[] {
+ const rows = asFeeArray(payload.route_rows || payload.landSegments || payload.land_segments);
+ const out: TicketQuoteFee[] = [];
+ const seen = new Set();
+ for (const row of rows) {
+ if (!row || typeof row !== "object") continue;
+ const fuel = formatLandPercent(row.fuel_surcharge_rate ?? row.fuelSurchargeRate);
+ const tax = formatLandTaxRate(row.tax_rate ?? row.taxRate ?? row.rate);
+ const included = formatLandTaxIncluded(
+ row.tax_included ?? row.rateFlag ?? row.rate_flag
+ );
+ if (fuel) {
+ const key = `fuel:${fuel}`;
+ if (!seen.has(key)) {
+ seen.add(key);
+ out.push({ name: "燃油附加费率", amount: fuel });
+ }
+ }
+ if (tax) {
+ const key = `tax:${tax}`;
+ if (!seen.has(key)) {
+ seen.add(key);
+ out.push({ name: "税率", amount: tax });
+ }
+ }
+ if (included) {
+ const key = `inc:${included}`;
+ if (!seen.has(key)) {
+ seen.add(key);
+ out.push({ name: "是否含税", amount: included });
+ }
+ }
+ }
+ return out;
+}
+
+/** 燃油附加费率:数字带 %。 */
+function formatLandPercent(raw: unknown): string {
+ const s = String(raw ?? "").trim();
+ if (!s || s === "-" || s.toLowerCase() === "null") return "";
+ if (s.includes("%")) return s;
+ if (/^-?\d+(\.\d+)?$/.test(s)) {
+ return `${s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
+ }
+ return s;
+}
+
+/** 税率 rate:0.09→9%;大于 1 按百分点。 */
+function formatLandTaxRate(raw: unknown): string {
+ const s = String(raw ?? "").trim();
+ if (!s || s === "-" || s.toLowerCase() === "null") return "";
+ if (s.includes("%")) return s;
+ if (!/^-?\d+(\.\d+)?$/.test(s)) return s;
+ const n = Number(s);
+ if (!Number.isFinite(n)) return s;
+ const abs = Math.abs(n);
+ const pct = abs > 0 && abs <= 1 ? n * 100 : n;
+ return `${String(pct).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
+}
+
+/** rateFlag:1=含税,0=不含税。 */
+function formatLandTaxIncluded(raw: unknown): string {
+ const s = String(raw ?? "").trim();
+ if (!s || s === "-" || s.toLowerCase() === "null") return "";
+ if (s === "1" || s.toLowerCase() === "true" || s === "是" || s === "含税") return "含税";
+ if (s === "0" || s.toLowerCase() === "false" || s === "否" || s === "不含税") return "不含税";
+ return s;
+}
+
/**
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
|