空运人工价锁舱现拉补全舱位并拦清缺参;成交/报价填表与工单陆运费率展示同步收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-07 23:35:24 +08:00
co-authored by Cursor
parent 419b4a1bf7
commit e197ecea10
31 changed files with 1607 additions and 150 deletions
+245 -12
View File
@@ -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("["):
+31 -3
View File
@@ -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:
+78 -1
View File
@@ -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 "")
+115 -12
View File
@@ -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。
+63 -4
View File
@@ -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,
@@ -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)