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

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)
@@ -2053,6 +2053,16 @@ class AirTextInquiryFlow:
followup_hours=result.get("negotiate_hours"),
)
)
# 私聊点已成交:催锁发到空运协同群。海运陆运、还没建群的不发。
if status == "已成交" and (sess.business_line or "").strip().upper() == "AIR":
from agent.policy.air_group_ops import prompt_airline_to_lock
prompt_airline_to_lock(
flow=self,
ticket=sess,
chat_id=sess.collab_chat_id,
work_order_no=sess.work_order_no,
)
return phase
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
+43 -38
View File
@@ -177,45 +177,50 @@ def finish_deal_outcome(
staff = list(lister(role_code=_role_of(business_line)) or [])
except Exception:
logger.exception("成交匹配负责人失败 wo=%s", no)
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
if not leaders:
summary = "无匹配负责人"
elif not ready:
# 通知卡拼字段失败不能挡住六态「已成交」和群回执(WO001:selected_air_option 非字典)。
try:
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
if not leaders:
summary = "无匹配负责人"
elif not ready:
summary = "通知未发出"
else:
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
summary = "已推:" + "、".join(n for n in names if n)
sender = enqueue or _enqueue_default
payload = copy.deal_notify_wecom_payload(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
text = copy.deal_notify_card(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
continue
try:
ok, info = sender(
touser=uid,
content=text,
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
except Exception:
logger.exception("成交通知组装失败,仍继续入账 wo=%s", no)
summary = "通知未发出"
else:
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
summary = "已推:" + "、".join(n for n in names if n)
sender = enqueue or _enqueue_default
payload = copy.deal_notify_wecom_payload(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
text = copy.deal_notify_card(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
continue
try:
ok, info = sender(
touser=uid,
content=text,
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
extra["deal_notify_summary"] = summary
extra["remark"] = summary
writer = getattr(ledger, "transition", None)
+56 -1
View File
@@ -3420,8 +3420,63 @@ def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
)
def air_lock_ask_airline(*, work_order_no: str, airline_names: list[str] | None = None) -> str:
"""
已成交后点名航线去锁舱。只在空运群另发,不改共用的「已记录为已成交」。
后台 inquiring.air_lock_ask_airline。姓名来自岗位「航线·空运」。
"""
no = (work_order_no or "").strip()
names = [str(n or "").strip() for n in (airline_names or []) if str(n or "").strip()]
at = " ".join(f"@{n}" for n in names) if names else "@航线人员"
return _cabin_copy(
"inquiring.air_lock_ask_airline",
f"{no}请锁舱{at}",
ticket_no=no,
airline_names=at,
)
def air_lock_need_deal(*, sales_name: str) -> str:
"""
销售还没回复成交、未成交或协商中,航线就要锁。点名本单销售。
后台 inquiring.air_lock_need_deal。
"""
name = (sales_name or "").strip() or "销售"
return _cabin_copy(
"inquiring.air_lock_need_deal",
f"请销售@{name}先确认工单「已成交」",
sales_name=name,
)
def air_lock_blocked_negotiating(work_order_no: str) -> str:
"""协商中已经回过,不能再当成没回复。后台 inquiring.air_lock_negotiating。"""
no = (work_order_no or "").strip()
return _cabin_copy(
"inquiring.air_lock_negotiating",
f"工单{no}还在协商中,成交后才能锁舱。",
ticket_no=no,
)
def air_lock_blocked_lost(work_order_no: str) -> str:
"""未成交不能再请销售改口确认已成交。后台 inquiring.air_lock_lost。"""
no = (work_order_no or "").strip()
return _cabin_copy(
"inquiring.air_lock_lost",
f"工单{no}已是「未成交」,不能锁舱。",
ticket_no=no,
)
def air_release_deal_blocked(work_order_no: str) -> str:
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
"""
旧句:已成交不能取消舱位。
现口径是成交并锁舱之后仍可取消,群里不再发这句。后台编码保留,避免旧配置对不上。
"""
no = (work_order_no or "").strip()
return _cabin_copy(
"inquiring.air_release_deal_blocked",
@@ -1753,6 +1753,12 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
_apply_fee_amount(old, amount)
if item.get("currency"):
old["currency"] = item.get("currency")
# 盖价时一并写入单位/数量/分段:否则旧行默认「票」会盖住新抽到的「板」、
# project=起运港 也会丢,出单又落到错段或错单位(WO008/WO009)。
for key in ("unit", "unit_price", "unitPrice", "quantity", "project", "segment"):
val = item.get(key)
if val not in (None, ""):
old[key] = val
rows[idx] = old
out = dict(base or {})
for key, val in incoming.items():
@@ -913,6 +913,11 @@ def _resume_cabin_step(
service = "空运锁舱"
reason = "空运锁舱接口调用失败"
if not (out or {}).get("ok"):
err = str((out or {}).get("error") or "")
# 还没成交、协商中、未成交:业务拒绝,不标 TMS 异常、不通知 IT。
if err in {"lock_need_deal", "lock_negotiating", "lock_lost", "ticket_closed"}:
reply(str((out or {}).get("failReason") or "还不能锁舱"))
return {"ok": False, "error": err}
why = copy.air_cabin_fail_reason(
tms_reason=str((out or {}).get("failReason") or (out or {}).get("error") or ""),
flight_date=str(extra.get("flight_date") or ""),