""" 空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、锁舱/释放、成交跟进。 本文件职责:群里副作用(主账 + 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