必填核对后再建单;多条线路走 H5 点选;费用只展示该线路 TMS 回包,不套用整单空运费。 Co-authored-by: Cursor <cursoragent@cursor.com>
973 lines
34 KiB
Python
973 lines
34 KiB
Python
"""
|
|
空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、锁舱/释放、成交跟进。
|
|
|
|
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
|
|
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
|
|
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
|
|
from agent.policy import inquiry_copy as copy
|
|
from agent.policy.sea_group_ops import (
|
|
_archive_file,
|
|
_persist_quote_sheet,
|
|
_staff_display_name,
|
|
emit_group_deal,
|
|
filename_is_excel_pdf,
|
|
group_client_of,
|
|
halt_closed_group,
|
|
hydrate_ticket,
|
|
is_handoff_ticket,
|
|
is_sales,
|
|
is_terminal,
|
|
merge_quote_fees,
|
|
notify_quote_attachment_saved,
|
|
send_filled_sheet,
|
|
send_group_text,
|
|
ticket_no,
|
|
ticket_status,
|
|
ticket_view,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TERMINAL = {"已成交", "未成交", "已关闭", "转人工"}
|
|
OPEN_ACTIVATE = {"询价中", "已报价", "协商中"}
|
|
AIRLINE_ROLE = "air"
|
|
|
|
_PENDING_QUOTE = "__pending_air_quote"
|
|
_PENDING_LOCK = "__pending_air_lock"
|
|
_LOCK_ID = "__air_lock_id"
|
|
_LOCK_OPTION = "__air_lock_option"
|
|
_LOCK_QUOTE = "__air_lock_quote"
|
|
_LOCK_EXEC = "__air_lock_exec"
|
|
|
|
|
|
def _ticket_flight_date(ticket: object) -> str:
|
|
"""锁舱 flightDate 用工单报价日期,和主账出站一致。"""
|
|
view = ticket_view(ticket)
|
|
facts = dict(view.get("facts") or {})
|
|
return str(
|
|
facts.get("报价日期")
|
|
or view.get("quoteDate")
|
|
or view.get("quote_date")
|
|
or ""
|
|
).strip()
|
|
|
|
|
|
def is_airline(flow: Any, sender_id: str) -> bool:
|
|
"""员工管理岗位=航线·空运且启用。"""
|
|
uid = (sender_id or "").strip()
|
|
if not uid:
|
|
return False
|
|
book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None)
|
|
getter = getattr(book, "get_staff_by_wecom_id", None) if book else None
|
|
if not callable(getter):
|
|
return False
|
|
try:
|
|
row = getter(wecom_id=uid) or {}
|
|
except Exception:
|
|
logger.exception("查航线岗失败 wecom=%s", uid)
|
|
return False
|
|
code = str(row.get("roleCode") or row.get("role_code") or "").strip().lower()
|
|
status = str(row.get("status") or "active").strip().lower()
|
|
return code == AIRLINE_ROLE and status not in {"disabled", "0", "停用"}
|
|
|
|
|
|
def operator_name(flow: Any, sender_id: str) -> str:
|
|
book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None)
|
|
getter = getattr(book, "get_staff_by_wecom_id", None) if book else None
|
|
if callable(getter):
|
|
try:
|
|
row = getter(wecom_id=sender_id) or {}
|
|
name = str(row.get("name") or "").strip()
|
|
if name:
|
|
return name
|
|
except Exception:
|
|
logger.warning("取操作人姓名失败 wecom=%s", sender_id)
|
|
return (sender_id or "").strip() or "航线"
|
|
|
|
|
|
def can_fill_fields(flow: Any, ticket: object, sender_id: str) -> bool:
|
|
return is_sales(ticket, sender_id) or is_airline(flow, sender_id)
|
|
|
|
|
|
def activate_ticket(
|
|
*,
|
|
flow: Any,
|
|
chat_id: str,
|
|
work_order_no: str,
|
|
speak: bool = True,
|
|
) -> tuple[Optional[object], str]:
|
|
"""
|
|
把本群当前工单切到指定空运未结案单。
|
|
|
|
返回 (工单, phase)。失败时工单为 None。
|
|
"""
|
|
book = flow.ledger
|
|
no = (work_order_no or "").strip()
|
|
client = group_client_of(flow)
|
|
getter = getattr(book, "activate_air_group", None)
|
|
if callable(getter):
|
|
out = getter(chat_id=chat_id, work_order_no=no) or {}
|
|
else:
|
|
out = _activate_fallback(book, chat_id=chat_id, work_order_no=no)
|
|
err = str(out.get("error") or "")
|
|
if err == "ticket_not_found":
|
|
if speak:
|
|
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
|
|
return None, "ticket_missing"
|
|
if err == "not_air":
|
|
if speak:
|
|
send_group_text(client, chat_id, copy.air_ticket_not_air(no))
|
|
return None, "not_air"
|
|
if err == "terminal":
|
|
st = str(out.get("status") or "")
|
|
if st == "转人工":
|
|
return None, "handoff_ignore"
|
|
if speak:
|
|
send_group_text(
|
|
client, chat_id, copy.air_ticket_closed(no, st)
|
|
)
|
|
return None, "ticket_closed"
|
|
if err == "occupied":
|
|
if speak:
|
|
send_group_text(client, chat_id, copy.air_ticket_occupied(no))
|
|
return None, "occupied"
|
|
if not out.get("ok"):
|
|
if speak:
|
|
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
|
|
return None, "ticket_missing"
|
|
ticket = hydrate_ticket(flow, _ticket_from_activate(book, no, out))
|
|
if is_handoff_ticket(ticket):
|
|
return None, "handoff_ignore"
|
|
if speak:
|
|
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
|
|
_watch_archive_room(chat_id, no)
|
|
from agent.policy.event_exception import WAIT_AIR_QUOTE, start_wait
|
|
|
|
start_wait(flow.ledger, work_order_no=no, wait_kind=WAIT_AIR_QUOTE)
|
|
return ticket, "activated"
|
|
|
|
|
|
def send_air_brief_and_sheet(
|
|
*,
|
|
flow: Any,
|
|
ticket: object,
|
|
chat_id: str = "",
|
|
quote_override: Optional[dict[str, Any]] = None,
|
|
tms_quote: Optional[dict[str, Any]] = None,
|
|
product_quote: Optional[dict[str, Any]] = None,
|
|
) -> str:
|
|
"""
|
|
空运群再发一版摘要 + 匹配到的空运模板。对齐海运 send_brief_and_sheet。
|
|
|
|
必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。
|
|
"""
|
|
ticket = hydrate_ticket(flow, ticket)
|
|
view = ticket_view(ticket)
|
|
no = ticket_no(ticket)
|
|
room = str(chat_id or view.get("collab_chat_id") or view.get("collabChatId") or "").strip()
|
|
if room and not view.get("collab_chat_id"):
|
|
view["collab_chat_id"] = room
|
|
facts = dict(view.get("facts") or {})
|
|
from agent.schema.field_validate import AIR_COLLAB_FIELDS, pick_collab_from_facts
|
|
|
|
seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS)
|
|
collab = dict(view.get("collab_facts") or {})
|
|
if seeded:
|
|
changed = False
|
|
for k, v in seeded.items():
|
|
if v and not str(collab.get(k) or "").strip():
|
|
collab[k] = v
|
|
changed = True
|
|
if changed:
|
|
view["collab_facts"] = collab
|
|
patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None)
|
|
if callable(patcher):
|
|
patcher(
|
|
work_order_no=no,
|
|
facts=seeded,
|
|
sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""),
|
|
)
|
|
view["collab_facts"] = collab
|
|
view_q = dict(view.get("quote") or {})
|
|
override = dict(quote_override or {})
|
|
tms = dict(tms_quote) if tms_quote is not None else {}
|
|
manual = dict(product_quote or {})
|
|
if tms_quote is None:
|
|
tms = copy.resolve_tms_quote(None, view)
|
|
if not tms and view_q and copy.quote_is_tms(view_q):
|
|
tms = view_q
|
|
if not manual and copy.quote_is_product(override or view_q):
|
|
manual = override or view_q
|
|
quote = dict(override or view_q or tms or manual)
|
|
sales_name, airline_names, mention_ids = resolve_air_mentions(flow, view)
|
|
send_group_text(
|
|
group_client_of(flow),
|
|
room,
|
|
copy.air_group_brief(
|
|
work_order_no=no,
|
|
facts=facts,
|
|
quote=quote,
|
|
collab_facts=collab,
|
|
has_price=bool(tms.get("total") or tms.get("quoteId")),
|
|
tms_quote=tms,
|
|
product_quote=manual,
|
|
sales_name=sales_name,
|
|
airline_names=airline_names,
|
|
),
|
|
mention_userids=mention_ids,
|
|
)
|
|
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=quote)
|
|
return "brief_and_sheet"
|
|
|
|
|
|
def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], list[str]]:
|
|
"""
|
|
空运群摘要必须 @ 到本单销售和航线·空运,对上海运 @ 销售/@ 产品。
|
|
"""
|
|
view = ticket_view(ticket)
|
|
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
|
sales_name = str(view.get("sales_name") or view.get("salesName") or "").strip()
|
|
if not sales_name or sales_name == "销售":
|
|
sales_name = _staff_display_name(flow, sales_id) if sales_id else ""
|
|
if not sales_name:
|
|
sales_name = sales_id
|
|
air_ids: list[str] = []
|
|
air_names: list[str] = []
|
|
book = getattr(flow, "ledger", None)
|
|
lister = getattr(book, "list_staff_by_role", None) if book is not None else None
|
|
rows: list[dict[str, Any]] = []
|
|
if callable(lister):
|
|
try:
|
|
rows = list(lister(role_code="air") or [])
|
|
except Exception:
|
|
logger.exception("列航线岗失败")
|
|
for row in rows:
|
|
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
|
name = str(row.get("name") or "").strip() or _staff_display_name(flow, uid)
|
|
if not uid:
|
|
continue
|
|
air_ids.append(uid)
|
|
if name and name not in air_names:
|
|
air_names.append(name)
|
|
mention_ids: list[str] = []
|
|
if sales_id:
|
|
mention_ids.append(sales_id)
|
|
for uid in air_ids:
|
|
if uid not in mention_ids:
|
|
mention_ids.append(uid)
|
|
return sales_name, air_names, mention_ids
|
|
|
|
|
|
def _send_air_activate_sheet(
|
|
*,
|
|
flow: Any,
|
|
ticket: object,
|
|
chat_id: str,
|
|
quote: dict[str, Any],
|
|
) -> None:
|
|
"""填匹配到的空运模板并往群里发。失败只补一句,不回滚激活。"""
|
|
client = group_client_of(flow)
|
|
try:
|
|
painted = send_filled_sheet(
|
|
flow=flow,
|
|
ticket=ticket,
|
|
business_line="AIR",
|
|
chat_id=chat_id,
|
|
quote_override=quote,
|
|
) or {}
|
|
except Exception:
|
|
logger.exception("空运群发模板失败 chat=%s", chat_id)
|
|
send_group_text(client, chat_id, copy.group_sheet_send_fail())
|
|
return
|
|
if painted.get("ok"):
|
|
return
|
|
err = str(painted.get("error") or "")
|
|
if err == "NO_TEMPLATE":
|
|
send_group_text(client, chat_id, painted.get("message") or copy.QUOTE_TEMPLATE_MISSING)
|
|
return
|
|
if err in {"no_renderer_or_chat", "no_file_client"}:
|
|
send_group_text(client, chat_id, copy.group_sheet_send_fail())
|
|
|
|
|
|
def _watch_archive_room(chat_id: str, work_order_no: str) -> None:
|
|
"""激活成功后听这个群的存档,否则发文件(不能 @)系统听不见。"""
|
|
try:
|
|
from agent.channel.wshoto.rooms import watch_collab_room
|
|
|
|
watch_collab_room(chat_id, work_order_no=work_order_no)
|
|
except Exception:
|
|
logger.exception("空运群登记存档监听失败 chat=%s wo=%s", chat_id, work_order_no)
|
|
|
|
|
|
def resolve_cabin_ticket(
|
|
*,
|
|
flow: Any,
|
|
chat_id: str,
|
|
work_order_no: str,
|
|
allow_terminal: bool,
|
|
) -> tuple[Optional[object], str]:
|
|
"""
|
|
锁舱/释放按句中工单号取单,不猜当前单。
|
|
|
|
未结案:激活并切到本群。已在别的群:occupied,不锁不切。
|
|
已成交/未成交:不能再激活,但释放仍要读到这张单(成交拦住、未成交放舱)。
|
|
"""
|
|
book = flow.ledger
|
|
no = (work_order_no or "").strip()
|
|
client = group_client_of(flow)
|
|
getter = getattr(book, "get_ticket", None)
|
|
raw = getter(work_order_no=no) if callable(getter) else None
|
|
if raw is None:
|
|
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
|
|
return None, "ticket_missing"
|
|
ticket = hydrate_ticket(flow, raw)
|
|
view = ticket_view(ticket)
|
|
line = str(view.get("business_line") or view.get("businessLine") or "").upper()
|
|
if line and line != "AIR":
|
|
send_group_text(client, chat_id, copy.air_ticket_not_air(no))
|
|
return None, "not_air"
|
|
other = str(view.get("collab_chat_id") or view.get("collabChatId") or "").strip()
|
|
if other and other != chat_id:
|
|
send_group_text(client, chat_id, copy.air_ticket_occupied(no))
|
|
return None, "occupied"
|
|
if is_terminal(ticket):
|
|
if allow_terminal:
|
|
return ticket, "terminal_loaded"
|
|
if ticket_status(ticket) == "转人工":
|
|
return None, "handoff_ignore"
|
|
send_group_text(
|
|
client, chat_id, copy.air_ticket_closed(no, ticket_status(ticket))
|
|
)
|
|
return None, "ticket_closed"
|
|
return activate_ticket(flow=flow, chat_id=chat_id, work_order_no=no, speak=False)
|
|
|
|
|
|
def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, chat_id: str) -> str:
|
|
"""
|
|
补含电/含磁。对齐海运:写入后重发群摘要 + 空运模板,不当一句「已记下」。
|
|
"""
|
|
ticket = hydrate_ticket(flow, ticket)
|
|
no = ticket_no(ticket)
|
|
client = group_client_of(flow)
|
|
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
|
|
if stopped:
|
|
return stopped
|
|
if not can_fill_fields(flow, ticket, sender_id):
|
|
send_group_text(client, chat_id, copy.air_fields_denied())
|
|
return "fields_denied"
|
|
fields = extract_air_collab_fields(text, allow_b=True)
|
|
if not fields:
|
|
send_group_text(client, chat_id, copy.air_fields_not_recognized())
|
|
return "fields_empty"
|
|
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
|
ticket2 = current_ticket(flow, chat_id) or ticket
|
|
view = ticket_view(ticket2)
|
|
collab = dict(view.get("collab_facts") or {})
|
|
collab.update(fields)
|
|
view["collab_facts"] = collab
|
|
view["collab_chat_id"] = chat_id
|
|
send_air_brief_and_sheet(flow=flow, ticket=view, chat_id=chat_id)
|
|
return "fields_saved"
|
|
|
|
|
|
def apply_air_quote(
|
|
*,
|
|
flow: Any,
|
|
ticket: object,
|
|
text: str,
|
|
sender_id: str,
|
|
chat_id: str,
|
|
injected_quote: Optional[dict[str, Any]] = None,
|
|
) -> str:
|
|
"""
|
|
航线文字报价。对齐海运产品报价:同句可带含电含磁;有 TMS 价先问怎么用;
|
|
落定后重发摘要 + 空运模板 + 成交跟进。
|
|
"""
|
|
ticket = hydrate_ticket(flow, ticket)
|
|
no = ticket_no(ticket)
|
|
client = group_client_of(flow)
|
|
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
|
|
if stopped:
|
|
return stopped
|
|
if not is_airline(flow, sender_id):
|
|
send_group_text(client, chat_id, copy.air_airline_only_quote())
|
|
return "not_airline"
|
|
fields = extract_air_collab_fields(text)
|
|
if fields:
|
|
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
|
quote = injected_quote or extract_product_quote(text) or {}
|
|
if not quote:
|
|
send_group_text(client, chat_id, copy.air_quote_not_recognized())
|
|
return "quote_empty"
|
|
view = ticket_view(ticket)
|
|
current = dict(view.get("quote") or {})
|
|
if _has_tms_quote(current) and not _is_product_quote(current):
|
|
_park_air_pending(flow, no, quote, sender_id)
|
|
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
|
return "wait_tms_choice"
|
|
from agent.schema.quote_adjust import quote_fee_rows
|
|
|
|
if quote_fee_rows(current):
|
|
quote = merge_quote_fees(current, quote)
|
|
return _commit_quote(flow, ticket, quote, chat_id, sender_id)
|
|
|
|
|
|
def apply_air_file(
|
|
*,
|
|
flow: Any,
|
|
ticket: object,
|
|
sender_id: str,
|
|
filename: str,
|
|
media: dict[str, Any],
|
|
chat_id: str,
|
|
) -> str:
|
|
"""
|
|
空运群附件:对齐海运。只收 Excel/PDF。航线的当正式报价并出成交跟进;别人的只存档。
|
|
|
|
发文件不能 @,马上回「附件已保存」。不拆文件金额。已有 TMS 报价编号要留下。
|
|
"""
|
|
ticket = hydrate_ticket(flow, ticket)
|
|
no = ticket_no(ticket)
|
|
client = group_client_of(flow)
|
|
stopped = halt_closed_group(
|
|
flow=flow, ticket=ticket, chat_id=chat_id, file=True
|
|
)
|
|
if stopped:
|
|
return stopped
|
|
if not filename_is_excel_pdf(filename):
|
|
send_group_text(client, chat_id, copy.group_only_excel_pdf())
|
|
return "file_type_rejected"
|
|
_archive_file(flow, no, filename, media, sender_id)
|
|
notify_quote_attachment_saved(flow, chat_id, no, count=1)
|
|
if not is_airline(flow, sender_id):
|
|
return "file_archived"
|
|
view = ticket_view(ticket)
|
|
quote = dict(view.get("quote") or {})
|
|
quote["source_label"] = "航线附件报价"
|
|
quote["file_name"] = filename
|
|
if not quote.get("total"):
|
|
quote["total"] = "-"
|
|
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价") or {}
|
|
if not saved.get("ok"):
|
|
send_group_text(client, chat_id, "主账写入报价失败。")
|
|
return "ledger_fail"
|
|
view["collab_chat_id"] = chat_id
|
|
view["quote"] = quote
|
|
from agent.policy.event_exception import clear_wait
|
|
|
|
clear_wait(flow.ledger, work_order_no=no)
|
|
emit_group_deal(flow=flow, ticket=view, quote=quote)
|
|
return "airline_file_quote"
|
|
|
|
|
|
def bound_air_ticket(flow: Any, chat_id: str) -> Optional[object]:
|
|
"""这群当前绑的是空运单才算空运协同群。海运应用群不要走这里。"""
|
|
hit = current_ticket(flow, chat_id)
|
|
if hit is None:
|
|
return None
|
|
line = str(ticket_view(hit).get("business_line") or "").upper()
|
|
return hit if line == "AIR" else None
|
|
|
|
|
|
def apply_air_tms_choice(
|
|
*,
|
|
flow: Any,
|
|
ticket: object,
|
|
sender_id: str,
|
|
chat_id: str,
|
|
keep_tms: bool,
|
|
) -> str:
|
|
"""
|
|
航线选:在 TMS 上改,或不用 TMS。对齐海运:用刚记下的价出摘要 + 空运模板 + 成交跟进。
|
|
"""
|
|
ticket = hydrate_ticket(flow, ticket)
|
|
client = group_client_of(flow)
|
|
no = ticket_no(ticket)
|
|
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
|
|
if stopped:
|
|
return stopped
|
|
if not is_airline(flow, sender_id):
|
|
send_group_text(client, chat_id, copy.air_airline_only_quote())
|
|
return "not_airline"
|
|
pending = _load_air_pending(ticket, no)
|
|
if not pending:
|
|
send_group_text(client, chat_id, copy.group_need_price_before_choice())
|
|
return "quote_choice_no_pending"
|
|
view = ticket_view(ticket)
|
|
base = dict(view.get("quote") or {})
|
|
if keep_tms:
|
|
if pending.get("fee_rows") or pending.get("fee_lines"):
|
|
merged = merge_quote_fees(base, pending)
|
|
else:
|
|
merged = dict(base)
|
|
merged.update(pending)
|
|
if base.get("quoteId") and not pending.get("quoteId"):
|
|
merged["quoteId"] = base.get("quoteId")
|
|
if base.get("airOptions") and not pending.get("airOptions"):
|
|
merged["airOptions"] = base.get("airOptions")
|
|
merged["source_label"] = "航线在TMS上改"
|
|
else:
|
|
merged = dict(pending)
|
|
merged["source_label"] = "航线报价"
|
|
_clear_air_pending(flow, no, sender_id)
|
|
return _commit_quote(flow, ticket, merged, chat_id, sender_id, tms_quote=base if keep_tms else {})
|
|
|
|
|
|
def apply_air_confirm_tms(*, flow: Any, ticket: object, sender_id: str, chat_id: str) -> str:
|
|
"""航线确认沿用 TMS:模板入后台,回附件已保存,再出成交跟进。对齐海运。"""
|
|
ticket = hydrate_ticket(flow, ticket)
|
|
if not is_airline(flow, sender_id):
|
|
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_quote())
|
|
return "not_airline"
|
|
view = ticket_view(ticket)
|
|
quote = dict(view.get("quote") or {})
|
|
if not _has_tms_quote(quote):
|
|
send_group_text(group_client_of(flow), chat_id, copy.group_confirm_need_tms())
|
|
return "need_tms"
|
|
quote["source_label"] = "航线确认TMS"
|
|
no = ticket_no(ticket)
|
|
_clear_air_pending(flow, no, sender_id)
|
|
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价") or {}
|
|
if not saved.get("ok"):
|
|
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
|
|
return "ledger_fail"
|
|
view["collab_chat_id"] = chat_id
|
|
_persist_quote_sheet(
|
|
flow=flow,
|
|
ticket=view,
|
|
sess=None,
|
|
quote=quote,
|
|
sender_id=sender_id,
|
|
business_line="AIR",
|
|
)
|
|
notify_quote_attachment_saved(flow, chat_id, no, count=1)
|
|
from agent.policy.event_exception import clear_wait
|
|
|
|
clear_wait(flow.ledger, work_order_no=no)
|
|
emit_group_deal(flow=flow, ticket=view, quote=quote)
|
|
return "quote_confirmed"
|
|
|
|
|
|
def apply_air_deal(
|
|
*,
|
|
flow: Any,
|
|
ticket: object,
|
|
sender_id: str,
|
|
outcome: str,
|
|
chat_id: str,
|
|
) -> str:
|
|
if not is_sales(ticket, sender_id):
|
|
send_group_text(group_client_of(flow), chat_id, copy.air_sales_only_deal())
|
|
return "not_sales"
|
|
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
|
|
if stopped:
|
|
return stopped
|
|
from agent.policy.deal_outcome import finish_deal_outcome
|
|
from agent.policy.sea_group_ops import facts_of_group
|
|
|
|
view = ticket_view(ticket)
|
|
result = finish_deal_outcome(
|
|
ledger=flow.ledger,
|
|
work_order_no=ticket_no(ticket),
|
|
outcome=outcome,
|
|
facts=facts_of_group(view),
|
|
quote=dict(view.get("quote") or {}),
|
|
business_line="AIR",
|
|
sales_name=str(view.get("sales_name") or ""),
|
|
)
|
|
if not result.get("ok"):
|
|
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
|
|
return "ledger_fail"
|
|
send_group_text(
|
|
group_client_of(flow),
|
|
chat_id,
|
|
copy.deal_ack(outcome, ticket_no(ticket), str(result.get("next_followup_at") or "")),
|
|
)
|
|
return str(result.get("phase") or "done")
|
|
|
|
|
|
def apply_air_lock(
|
|
*,
|
|
flow: Any,
|
|
ticket: object,
|
|
sender_id: str,
|
|
chat_id: str,
|
|
instruction: str,
|
|
option_no: str = "",
|
|
) -> str:
|
|
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接口异常,IT运维排查中」。
|
|
no = ticket_no(ticket)
|
|
name = operator_name(flow, sender_id)
|
|
quote = ticket_view(ticket).get("quote") or {}
|
|
options = _air_options(quote)
|
|
quote_id = _quote_id(quote)
|
|
picked = (option_no or "").strip()
|
|
if not picked and len(options) > 1:
|
|
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_lock_option(options))
|
|
return "wait_lock_option"
|
|
if not picked and options:
|
|
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
|
|
if not picked:
|
|
picked = "AIR-OPT-01"
|
|
locker = getattr(flow.ledger, "lock_cabin", None)
|
|
if not callable(locker):
|
|
send_group_text(
|
|
group_client_of(flow),
|
|
chat_id,
|
|
copy.air_lock_fail(work_order_no=no, operator=name, reason="主账未开放锁舱"),
|
|
)
|
|
return "lock_unavailable"
|
|
from agent.policy.system_exception import (
|
|
STEP_TMS_LOCK,
|
|
TYPE_TMS,
|
|
SystemExceptionEvent,
|
|
call_with_retries,
|
|
confirm_system_exception,
|
|
)
|
|
|
|
out = call_with_retries(
|
|
lambda: locker(
|
|
work_order_no=no,
|
|
option_no=picked,
|
|
sender_id=sender_id,
|
|
operator_name=name,
|
|
instruction_text=instruction,
|
|
)
|
|
or {},
|
|
is_ok=lambda x: bool((x or {}).get("ok")),
|
|
)
|
|
if not out.get("ok"):
|
|
why = copy.air_cabin_fail_reason(
|
|
tms_reason=str(out.get("failReason") or out.get("error") or ""),
|
|
flight_date=_ticket_flight_date(ticket),
|
|
)
|
|
send_group_text(
|
|
group_client_of(flow),
|
|
chat_id,
|
|
copy.air_lock_fail(work_order_no=no, operator=name, reason=why),
|
|
)
|
|
confirm_system_exception(
|
|
event=SystemExceptionEvent(
|
|
exception_type=TYPE_TMS,
|
|
step=STEP_TMS_LOCK,
|
|
reason="空运锁舱接口调用失败",
|
|
service="空运锁舱",
|
|
error_code=why or "LOCK_FAIL",
|
|
work_order_no=no,
|
|
ticket_status=ticket_status(ticket),
|
|
conversation_kind="group",
|
|
conversation_target=chat_id,
|
|
extra={
|
|
"option_no": picked,
|
|
"instruction": instruction,
|
|
"sender_id": sender_id,
|
|
"operator_name": name,
|
|
"flight_date": _ticket_flight_date(ticket),
|
|
},
|
|
),
|
|
ledger=flow.ledger,
|
|
)
|
|
return "system_exception"
|
|
exec_no = str(out.get("lockId") or out.get("tmsRequestId") or "").strip()
|
|
quote_no = str(out.get("quoteId") or quote_id).strip()
|
|
flow.ledger.patch_collab_facts(
|
|
work_order_no=no,
|
|
facts={
|
|
_LOCK_ID: exec_no,
|
|
_LOCK_OPTION: picked,
|
|
_LOCK_QUOTE: quote_no,
|
|
_LOCK_EXEC: exec_no,
|
|
_PENDING_LOCK: "",
|
|
},
|
|
sender_id=sender_id,
|
|
)
|
|
send_group_text(
|
|
group_client_of(flow),
|
|
chat_id,
|
|
copy.air_lock_ok(
|
|
work_order_no=no,
|
|
operator=name,
|
|
option_no=picked,
|
|
exec_no=exec_no,
|
|
quote_no=quote_no,
|
|
),
|
|
)
|
|
from agent.policy.system_exception import clear_system_exception, ticket_is_paused
|
|
|
|
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 apply_air_release(
|
|
*,
|
|
flow: Any,
|
|
ticket: object,
|
|
sender_id: str,
|
|
chat_id: str,
|
|
instruction: str,
|
|
) -> str:
|
|
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,回话用释放失败原因而不是接口异常兜底。
|
|
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)
|
|
if not lock_id:
|
|
send_group_text(group_client_of(flow), chat_id, copy.air_release_need_lock(no))
|
|
return "release_need_lock"
|
|
releaser = getattr(flow.ledger, "release_cabin", None)
|
|
if not callable(releaser):
|
|
send_group_text(
|
|
group_client_of(flow),
|
|
chat_id,
|
|
copy.air_release_fail(work_order_no=no, operator=name, reason="主账未开放释放"),
|
|
)
|
|
return "release_unavailable"
|
|
from agent.policy.system_exception import (
|
|
STEP_TMS_RELEASE,
|
|
TYPE_TMS,
|
|
SystemExceptionEvent,
|
|
call_with_retries,
|
|
confirm_system_exception,
|
|
)
|
|
|
|
out = call_with_retries(
|
|
lambda: releaser(
|
|
work_order_no=no,
|
|
lock_id=lock_id,
|
|
sender_id=sender_id,
|
|
operator_name=name,
|
|
instruction_text=instruction,
|
|
)
|
|
or {},
|
|
is_ok=lambda x: bool((x or {}).get("ok")),
|
|
)
|
|
if not out.get("ok"):
|
|
why = copy.air_cabin_fail_reason(
|
|
tms_reason=str(out.get("failReason") or out.get("error") or ""),
|
|
flight_date=_ticket_flight_date(ticket),
|
|
)
|
|
send_group_text(
|
|
group_client_of(flow),
|
|
chat_id,
|
|
copy.air_release_fail(work_order_no=no, operator=name, reason=why),
|
|
)
|
|
confirm_system_exception(
|
|
event=SystemExceptionEvent(
|
|
exception_type=TYPE_TMS,
|
|
step=STEP_TMS_RELEASE,
|
|
reason="空运释放舱位接口调用失败",
|
|
service="空运舱位",
|
|
error_code=why or "RELEASE_FAIL",
|
|
work_order_no=no,
|
|
ticket_status=ticket_status(ticket),
|
|
conversation_kind="group",
|
|
conversation_target=chat_id,
|
|
extra={
|
|
"lock_id": lock_id,
|
|
"instruction": instruction,
|
|
"sender_id": sender_id,
|
|
"operator_name": name,
|
|
"flight_date": _ticket_flight_date(ticket),
|
|
},
|
|
),
|
|
ledger=flow.ledger,
|
|
)
|
|
return "system_exception"
|
|
exec_no = str(out.get("releaseId") or out.get("tmsRequestId") or lock_id).strip()
|
|
send_group_text(
|
|
group_client_of(flow),
|
|
chat_id,
|
|
copy.air_release_ok(
|
|
work_order_no=no,
|
|
operator=name,
|
|
option_no=str(facts.get(_LOCK_OPTION) or ""),
|
|
exec_no=exec_no,
|
|
quote_no=str(facts.get(_LOCK_QUOTE) or ""),
|
|
),
|
|
)
|
|
from agent.policy.system_exception import clear_system_exception, ticket_is_paused
|
|
|
|
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 "release_ok"
|
|
|
|
|
|
def current_ticket(flow: Any, chat_id: str) -> Optional[object]:
|
|
book = flow.ledger
|
|
hit = book.find_by_collab_chat(chat_id=chat_id)
|
|
if hit is None:
|
|
return None
|
|
return hydrate_ticket(flow, hit)
|
|
|
|
|
|
def _commit_quote(
|
|
flow: Any,
|
|
ticket: object,
|
|
quote: dict[str, Any],
|
|
chat_id: str,
|
|
sender_id: str,
|
|
tms_quote: Optional[dict[str, Any]] = None,
|
|
) -> str:
|
|
"""报价落定:写入主账,重发摘要 + 空运模板,再出成交跟进。"""
|
|
_ = sender_id
|
|
no = ticket_no(ticket)
|
|
up = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价")
|
|
if not up.get("ok"):
|
|
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
|
|
return "ledger_fail"
|
|
from agent.policy.event_exception import clear_wait
|
|
|
|
clear_wait(flow.ledger, work_order_no=no)
|
|
ticket2 = current_ticket(flow, chat_id) or ticket
|
|
view = ticket_view(ticket2)
|
|
view["collab_chat_id"] = chat_id
|
|
view["quote"] = quote
|
|
send_air_brief_and_sheet(
|
|
flow=flow,
|
|
ticket=view,
|
|
chat_id=chat_id,
|
|
quote_override=quote,
|
|
tms_quote=tms_quote if tms_quote is not None else {},
|
|
product_quote=quote,
|
|
)
|
|
emit_group_deal(flow=flow, ticket=view, quote=quote)
|
|
return "quoted"
|
|
|
|
|
|
def peek_air_pending(ticket: object) -> dict[str, Any]:
|
|
"""这张单是否还在等航线选怎么用 TMS。"""
|
|
return _load_air_pending(ticket, ticket_no(ticket))
|
|
|
|
|
|
def _park_air_pending(flow: Any, work_order_no: str, quote: dict[str, Any], sender_id: str) -> None:
|
|
"""先记下航线刚报的价,不写主账报价版本。Redis 正式;协同字段兜底给单测。"""
|
|
try:
|
|
from agent.redis_coord.pending_quote import save_pending_product_quote
|
|
|
|
save_pending_product_quote(work_order_no, quote)
|
|
except Exception:
|
|
logger.exception("待确认航线报价写入失败 wo=%s", work_order_no)
|
|
flow.ledger.patch_collab_facts(
|
|
work_order_no=work_order_no,
|
|
facts={_PENDING_QUOTE: json.dumps(quote, ensure_ascii=False)},
|
|
sender_id=sender_id,
|
|
)
|
|
|
|
|
|
def _load_air_pending(ticket: object, work_order_no: str) -> dict[str, Any]:
|
|
try:
|
|
from agent.redis_coord.pending_quote import load_pending_product_quote
|
|
|
|
parked = load_pending_product_quote(work_order_no)
|
|
if parked:
|
|
return parked
|
|
except Exception:
|
|
logger.exception("待确认航线报价读取失败 wo=%s", work_order_no)
|
|
raw = str((ticket_view(ticket).get("collab_facts") or {}).get(_PENDING_QUOTE) or "").strip()
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
return dict(data) if isinstance(data, dict) else {}
|
|
|
|
|
|
def _clear_air_pending(flow: Any, work_order_no: str, sender_id: str) -> None:
|
|
try:
|
|
from agent.redis_coord.pending_quote import clear_pending_product_quote
|
|
|
|
clear_pending_product_quote(work_order_no)
|
|
except Exception:
|
|
logger.exception("待确认航线报价清理失败 wo=%s", work_order_no)
|
|
flow.ledger.patch_collab_facts(
|
|
work_order_no=work_order_no,
|
|
facts={_PENDING_QUOTE: ""},
|
|
sender_id=sender_id,
|
|
)
|
|
|
|
|
|
def _has_tms_quote(quote: dict[str, Any]) -> bool:
|
|
"""当前价来自 TMS 才要问;航线/产品/销售调价不再替用户选。"""
|
|
src = dict(quote or {})
|
|
if not (src.get("total") or src.get("quoteId") or src.get("quote_id")):
|
|
return False
|
|
return copy.quote_is_tms(src)
|
|
|
|
|
|
def _is_product_quote(quote: dict[str, Any]) -> bool:
|
|
src = str(quote.get("source_label") or "")
|
|
return "航线" in src or "产品" in src
|
|
|
|
|
|
def _quote_id(quote: dict[str, Any]) -> str:
|
|
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
|
|
|
|
|
|
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("["):
|
|
try:
|
|
raw = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
raw = []
|
|
if isinstance(raw, list):
|
|
return [x for x in raw if isinstance(x, dict)]
|
|
blob = str(quote.get("air_options_json") or "").strip()
|
|
if blob.startswith("["):
|
|
try:
|
|
parsed = json.loads(blob)
|
|
except json.JSONDecodeError:
|
|
return []
|
|
return [x for x in parsed if isinstance(x, dict)]
|
|
return []
|
|
|
|
|
|
def _activate_fallback(book: Any, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
|
|
"""内存账本未实现 activate 时的兜底,单测应走正式方法。"""
|
|
getter = getattr(book, "get_ticket", None)
|
|
ticket = getter(work_order_no=work_order_no) if callable(getter) else None
|
|
if ticket is None:
|
|
return {"ok": False, "error": "ticket_not_found"}
|
|
return {"ok": True, "work_order_no": work_order_no}
|
|
|
|
|
|
def _ticket_from_activate(book: Any, no: str, out: dict[str, Any]) -> object:
|
|
getter = getattr(book, "get_ticket", None)
|
|
if callable(getter):
|
|
hit = getter(work_order_no=no)
|
|
if hit is not None:
|
|
return hit
|
|
return out
|