空运成交后可在本群继续锁舱;调价同步舱位费用桶;出单按空运段骨架落费并保留陆运车型尺寸;导出文件名乱码修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-14 15:34:24 +08:00
co-authored by Cursor
parent 92bdf56a4d
commit cc8529f569
29 changed files with 1941 additions and 276 deletions
+107 -17
View File
@@ -1,5 +1,5 @@
"""
空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、锁舱/释放、成交跟进。
空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、成交后再锁舱、成交后仍可取消舱位。
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
@@ -39,6 +39,7 @@ from agent.policy.sea_group_ops import (
logger = logging.getLogger(__name__)
TERMINAL = {"已成交", "未成交", "已关闭", "转人工"}
OPEN_ACTIVATE = {"询价中", "已报价", "协商中"}
AIRLINE_ROLE = "air"
@@ -535,12 +536,13 @@ def resolve_cabin_ticket(
chat_id: str,
work_order_no: str,
allow_terminal: bool,
for_lock: bool = False,
) -> tuple[Optional[object], str]:
"""
锁舱/释放按句中工单号取单,不猜当前单。
未结案:激活并切到本群。已在别的群:occupied,不锁不切。
已成交/未成交:不能再激活,但释放仍要读到这张单(成交拦住、未成交放舱)。
已成交可以锁、也可以取消已锁舱位,不再激活。未成交不能锁,但已有锁仍可取消。
"""
book = flow.ledger
no = (work_order_no or "").strip()
@@ -561,13 +563,18 @@ def resolve_cabin_ticket(
send_group_text(client, chat_id, copy.air_ticket_occupied(no))
return None, "occupied"
if is_terminal(ticket):
st = ticket_status(ticket)
# 锁舱只在已成交之后办。未成交要能读到单,才能回「不能锁」,而不是「不能再激活」。
if for_lock and st in {"已成交", "未成交"}:
# 已成交不再走普通激活,但要把本群记下,下一句补舱位不用再带工单号。
if st == "已成交":
_bind_deal_cabin_room(flow, chat_id=chat_id, work_order_no=no)
return ticket, "deal_loaded" if st == "已成交" else "lost_loaded"
if allow_terminal:
return ticket, "terminal_loaded"
if ticket_status(ticket) == "转人工":
if st == "转人工":
return None, "handoff_ignore"
send_group_text(
client, chat_id, copy.air_ticket_closed(no, ticket_status(ticket))
)
send_group_text(client, chat_id, copy.air_ticket_closed(no, st))
return None, "ticket_closed"
return activate_ticket(flow=flow, chat_id=chat_id, work_order_no=no, speak=False)
@@ -869,9 +876,91 @@ def apply_air_deal(
followup_hours=result.get("negotiate_hours"),
),
)
# 共用回执仍是「已记录为已成交」。催锁只加在空运群,海运陆运不走这里。
if outcome == "已成交" and phase == "done":
prompt_airline_to_lock(flow=flow, ticket=ticket, chat_id=chat_id, work_order_no=no)
return phase
def _bind_deal_cabin_room(flow: Any, *, chat_id: str, work_order_no: str) -> None:
"""已成交锁舱时把工单留在本群。普通激活仍拒绝已成交。失败只记日志,不挡这一句锁舱。"""
book = getattr(flow, "ledger", None)
activator = getattr(book, "activate_air_group", None) if book is not None else None
if not callable(activator):
return
try:
activator(chat_id=chat_id, work_order_no=work_order_no, allow_deal=True)
except TypeError:
logger.warning("主账激活不接受已成交绑群 wo=%s", work_order_no)
except Exception:
logger.exception("已成交锁舱绑群失败 wo=%s", work_order_no)
def prompt_airline_to_lock(
*,
flow: Any,
ticket: object,
chat_id: str,
work_order_no: str = "",
) -> None:
"""
空运单记成已成交后,在协同群点名航线锁舱。
只 @ 岗位为航线·空运的人,不改成交回执。没有群、或多段/海运/陆运,不发。
群消息在调用方线程发出(群回调或私聊成交后的同一轮)。
"""
view = ticket_view(ticket)
no = (work_order_no or ticket_no(ticket)).strip()
room = (chat_id or str(view.get("collab_chat_id") or view.get("collabChatId") or "")).strip()
line = str(view.get("business_line") or view.get("businessLine") or "AIR").strip().upper()
if not no or not room or line != "AIR":
return
_sales_name, airline_names, mention_ids = resolve_air_mentions(flow, ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
air_ids = [uid for uid in mention_ids if uid and uid != sales_id]
send_group_text(
group_client_of(flow),
room,
copy.air_lock_ask_airline(work_order_no=no, airline_names=airline_names),
mention_userids=air_ids,
)
def _block_lock_until_deal(flow: Any, ticket: object, chat_id: str) -> str:
"""
未成交的空运单不许锁。已成交返回空串,调用方继续锁。
询价中/已报价:点名本单销售。协商中、未成交、已关闭各说各的。
转人工沿用静默。不打 TMS。
"""
st = ticket_status(ticket)
if st == "已成交":
return ""
no = ticket_no(ticket)
client = group_client_of(flow)
if st == "未成交":
send_group_text(client, chat_id, copy.air_lock_blocked_lost(no))
return "lock_lost"
if st == "协商中":
send_group_text(client, chat_id, copy.air_lock_blocked_negotiating(no))
return "lock_negotiating"
if st == "转人工":
return "handoff_ignore"
if st == "已关闭":
send_group_text(client, chat_id, copy.air_ticket_closed(no, st))
return "ticket_closed"
sales_name, _airline_names, _mention_ids = resolve_air_mentions(flow, ticket)
view = ticket_view(ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
send_group_text(
client,
chat_id,
copy.air_lock_need_deal(sales_name=sales_name),
mention_userids=[sales_id] if sales_id else None,
)
return "lock_need_deal"
def apply_air_lock(
*,
flow: Any,
@@ -884,15 +973,19 @@ def apply_air_lock(
flight_no: str = "",
) -> str:
"""
航线锁舱。
航线锁舱。只有工单已是「已成交」才打主账。
还没回复成交:点名销售先确认。协商中、未成交各用一句,不催销售改口。
舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。
三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。
上次锁舱失败标过暂停的,航线再锁仍要打 TMS。
上次锁舱失败标过暂停的,航线再锁仍要打 TMS。锁成功不再推成交跟进。
"""
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
blocked = _block_lock_until_deal(flow, ticket, chat_id)
if blocked:
return blocked
no = ticket_no(ticket)
name = operator_name(flow, sender_id)
view_quote = dict(ticket_view(ticket).get("quote") or {})
@@ -950,12 +1043,15 @@ def apply_air_lock(
is_ok=lambda x: bool((x or {}).get("ok")),
)
if not out.get("ok"):
if str(out.get("error") or "") == "no_quote":
err_code = str(out.get("error") or "")
if err_code == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
# 主账再拦一遍成交状态:只回业务句,不当 TMS 故障。
if err_code in {"lock_need_deal", "lock_negotiating", "lock_lost", "ticket_closed"}:
return _block_lock_until_deal(flow, ticket, chat_id) or err_code
# 主账在出站前发现三项不齐:只请补,不当 TMS 故障,不通知 IT。
reason_text = str(out.get("failReason") or "")
err_code = str(out.get("error") or "")
if err_code == "lock_params_incomplete" or "锁舱参数不完整" in reason_text:
labels = missing or _labels_from_incomplete_reason(reason_text)
_remember_partial_lock(
@@ -1065,9 +1161,6 @@ def apply_air_lock(
if ticket_is_paused(ticket):
clear_system_exception(ledger=flow.ledger, work_order_no=no)
view = ticket_view(ticket)
view["collab_chat_id"] = chat_id
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
return "lock_ok"
def _release_should_push_deal(ticket: object) -> bool:
@@ -1091,11 +1184,8 @@ def apply_air_release(
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
# 释放同理:暂停单也允许再打 TMS,回话用释放失败原因而不是接口异常兜底。
# 释放同理:暂停单、已成交单也允许再打 TMS。还没锁过的仍拒绝。
no = ticket_no(ticket)
if ticket_status(ticket) == "已成交":
send_group_text(group_client_of(flow), chat_id, copy.air_release_deal_blocked(no))
return "release_deal_blocked"
facts = dict(ticket_view(ticket).get("collab_facts") or {})
lock_id = str(facts.get(_LOCK_ID) or "").strip()
name = operator_name(flow, sender_id)