空运人工价锁舱现拉补全舱位并拦清缺参;成交/报价填表与工单陆运费率展示同步收紧。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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("["):
|
||||
|
||||
Reference in New Issue
Block a user