空运成交后可在本群继续锁舱;调价同步舱位费用桶;出单按空运段骨架落费并保留陆运车型尺寸;导出文件名乱码修复。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""),
|
||||
|
||||
Reference in New Issue
Block a user