""" 空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、锁舱/释放、成交跟进。 本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。 禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。 锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也可走 MANUAL。 禁止编造 optionNo;缺舱位编号/航司/计费重时先群里补问,禁止空壳打 TMS。 """ from __future__ import annotations import json import logging import re 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" _PENDING_LOCK_OPTION = "__pending_lock_option" _PENDING_LOCK_AIRLINE = "__pending_lock_airline" _PENDING_LOCK_FLIGHT = "__pending_lock_flight" _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 is_air_ticket(ticket: object) -> bool: """按群找回的工单必须是空运。陆运旧单不能当空运当前单补含电含磁。""" view = ticket_view(ticket) line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper() return not line or line == "AIR" 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" # 先落默认协同,再决定要不要发摘要。发文件切单不说话,工单上仍要有含电/含磁。 _seed_air_activate_collab(flow, ticket) if speak and _air_already_quoted_for_deal(ticket): view = ticket_view(ticket) view["collab_chat_id"] = chat_id emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {})) _watch_archive_room(chat_id, no) return ticket, "activated_deal" 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 _air_already_quoted_for_deal(ticket: object) -> bool: """ 再激活应回成交跟进,而不是重发工单摘要和空运模板。 报价单已经发给销售(quoteFileIssued)或主账停在 wait_deal 时, 即使报价来源仍是 TMS 标准价,也已经在成交跟进。 协商中、航线/产品价、销售调价同样只回成交跟进。 报价单还没出、也没在等成交时,仍发模板给航线填。 """ status = ticket_status(ticket) if status == "协商中": return True view = ticket_view(ticket) phase = str(view.get("wait_phase") or view.get("waitPhase") or "").strip() if phase == "wait_deal": return True quote = dict(view.get("quote") or {}) if _quote_file_issued(quote): return True if status != "已报价": return False label = str(quote.get("source_label") or quote.get("sourceLabel") or "") if "航线" in label or "产品" in label: return True return copy.quote_is_sales_adjust(quote) def _quote_file_issued(quote: dict[str, Any]) -> bool: """报价单已发给销售,且没有被作废。作废后重新激活仍要发模板。""" if _flag_on(quote.get("quoteFileVoided") or quote.get("quote_file_voided")): return False return _flag_on(quote.get("quoteFileIssued") or quote.get("quote_file_issued")) def _flag_on(val: object) -> bool: """主账布尔可能是 true / \"true\" / 1。""" if isinstance(val, str): return val.strip().lower() in {"1", "true", "yes"} return bool(val) def _collab_slot_empty(val: object) -> bool: """协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。""" return str(val or "").strip() in {"", "-", "—", "-"} def _seed_air_activate_collab(flow: Any, ticket: object) -> dict[str, str]: """ 空运群激活时预填协同,并记到主账。 私聊已经写过的含电/含磁先带上。 货物品名正好是「普货」、该项还空着,默认否。 群里已经记下的值不覆盖。只补空项。 """ from agent.schema.field_validate import ( AIR_COLLAB_FIELDS, air_general_cargo_collab_defaults, pick_collab_from_facts, ) view = ticket_view(ticket) no = ticket_no(ticket) facts = dict(view.get("facts") or {}) # 主账有时品名在货物名称列,事实里没带「品名」。群摘要仍能看到普货。 if not str(facts.get("品名") or facts.get("货物品名") or "").strip(): cargo = str(view.get("goodsName") or view.get("goods_name") or "").strip() if cargo: facts["品名"] = cargo seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS) for key, val in air_general_cargo_collab_defaults(facts).items(): if _collab_slot_empty(seeded.get(key)): seeded[key] = val collab = dict(view.get("collab_facts") or {}) to_write: dict[str, str] = {} for key, val in seeded.items(): if val and _collab_slot_empty(collab.get(key)): collab[key] = val to_write[key] = val if to_write: patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None) if callable(patcher): patcher( work_order_no=no, facts=to_write, sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""), ) return collab 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。 必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。 填表报价必须带上 TMS 总销售价:群票上的 latest quote 常只有增点费/拆板费明细, 若直接拿去出单,预估费用会落成明细加总(如 200)而不是 salePrice(如 7918.25)。 """ 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 {}) collab = _seed_air_activate_collab(flow, view) 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) # 出单专用:把 TMS salePrice 盖回薄报价,避免预估费用=明细 SUM。 sheet_quote = _sheet_quote_with_tms_sale_total(quote, tms=tms, facts=facts) 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=sheet_quote) return "brief_and_sheet" def _air_usable_sale_total(quote: Optional[dict[str, Any]]) -> str: """ 空运出单可用的总销售价:顶层 total / salePrice,或选定/首条线路 salePrice。 「-」与空串不算。 """ src = dict(quote or {}) for key in ("total", "total_amount", "salePrice", "totalSalePrice"): val = str(src.get(key) or "").strip() if val and val != "-": return val selected = src.get("selected_air_option") or src.get("selectedAirOption") if isinstance(selected, dict): val = str(selected.get("salePrice") or selected.get("sale_price") or "").strip() if val and val != "-": return val raw_opts = src.get("airOptions") or src.get("air_options") or [] if isinstance(raw_opts, list) and raw_opts and isinstance(raw_opts[0], dict): val = str(raw_opts[0].get("salePrice") or raw_opts[0].get("sale_price") or "").strip() if val and val != "-": return val return "" def _money_plain_number(text: str) -> Optional[float]: """从「CNY 7918.25」类文本抽出数字;解不开返回 None。""" raw = str(text or "").strip() if not raw or raw == "-": return None digits = "".join(ch if (ch.isdigit() or ch in ".-") else " " for ch in raw).split() if not digits: return None try: return float(digits[-1].replace(",", "")) except ValueError: return None def _total_matches_fee_row_sum(quote: dict[str, Any], total_text: str) -> bool: """ 顶层 total 是否只是明细金额相加(如增点费100+拆板费100=200)。 是则说明缺了 TMS 总销售价,不能当真预估费用。 """ total_num = _money_plain_number(total_text) if total_num is None: return False rows = quote.get("fee_rows") or quote.get("fee_lines") or quote.get("feeItems") or [] if isinstance(rows, str) and rows.strip(): try: rows = json.loads(rows) except json.JSONDecodeError: rows = [] if not isinstance(rows, list) or not rows: return False summed = 0.0 hit = 0 for item in rows: if not isinstance(item, dict): continue amt = _money_plain_number( str( item.get("amount") or item.get("value") or item.get("salePrice") or item.get("price") or "" ) ) if amt is None: continue summed += amt hit += 1 if hit <= 0: return False return abs(total_num - summed) < 0.01 def _sheet_quote_with_tms_sale_total( quote: dict[str, Any], *, tms: Optional[dict[str, Any]] = None, facts: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: """ 群填表用的报价包:保留产品/航线改过的明细,但预估费用必须能落到 TMS 总销售价。 协同群 latest quote 常只有 departureCharges 投影出的附加费行,total 空或被加成 200; 销售 1V1 会话里带了 salePrice,所以同一工单两边会不一致。这里出单前对齐。 """ from agent.schema.air_route_options import project_group_air_quote out = dict(quote or {}) source = dict(tms or {}) or dict(out) if not source: return out projected = project_group_air_quote(source, dict(facts or {})) tms_total = _air_usable_sale_total(projected) cur_total = _air_usable_sale_total(out) # 补齐线路对象 / 计费重,便于主账从 airOptions.salePrice 再兜底一次。 for key in ( "airOptions", "air_options", "selected_air_option", "selectedAirOption", "chargeableWeightKg", "chargeable_weight_kg", "currency", "valid_until", ): cur = out.get(key) empty = cur in (None, "", "-", [], {}) proj_val = projected.get(key) if empty and proj_val not in (None, "", "-", [], {}): out[key] = proj_val if not tms_total: return out need_tms_total = (not cur_total) or cur_total == "-" or _total_matches_fee_row_sum(out, cur_total) if need_tms_total: out["total"] = str(projected.get("total") or tms_total).strip() if projected.get("currency") and not str(out.get("currency") or "").strip(): out["currency"] = projected.get("currency") return out 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) if not is_air_ticket(ticket): send_group_text(client, chat_id, copy.air_need_work_order()) return "need_work_order" 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) # 必须回读本单。按群再查会拿到同群更早的陆运/海运旧单(测服 WO202609230007)。 getter = getattr(flow.ledger, "get_ticket", None) reloaded = getter(work_order_no=no) if callable(getter) else None ticket2 = hydrate_ticket(flow, reloaded 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) # 确认 TMS 出单:同样用总销售价,避免只落附加费明细时预估费用=200。 sheet_quote = _sheet_quote_with_tms_sale_total( quote, tms=quote, facts=dict(view.get("facts") or {}) ) saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=sheet_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 view["quote"] = sheet_quote _persist_quote_sheet( flow=flow, ticket=view, sess=None, quote=sheet_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, text: 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, harvest_inline_lost_reason from agent.policy.sea_group_ops import _quote_fee_rows, facts_of_group view = ticket_view(ticket) no = ticket_no(ticket) quote = dict(view.get("quote") or {}) status_now = ticket_status(ticket) usable_total = str(quote.get("total") or "").strip() if usable_total in {"", "-"}: usable_total = "" # 与海运一致:询价中且无报价时先说清原因,禁止笼统「主账状态更新失败」。 if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}): send_group_text( group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no) ) return "deal_need_quote" inline = harvest_inline_lost_reason(text) if outcome == "未成交" else "" result = finish_deal_outcome( ledger=flow.ledger, work_order_no=no, outcome=outcome, facts=facts_of_group(view), quote=quote, business_line="AIR", sales_name=str(view.get("sales_name") or ""), lost_reason=inline, ) if not result.get("ok"): # 迁态失败且仍无报价时,同样用「没有报价」说明,避免旧笼统句。 err = str(result.get("error") or "") still_no_quote = not ( usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"} ) if still_no_quote or "询价中" in err: send_group_text( group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no) ) else: send_group_text( group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。" ) return "ledger_fail" phase = str(result.get("phase") or "done") ack_reason = inline if phase == "done" and outcome == "未成交" else "" send_group_text( group_client_of(flow), chat_id, copy.deal_ack( outcome, no, str(result.get("next_followup_at") or ""), lost_reason=ack_reason, ), ) return phase def apply_air_lock( *, flow: Any, ticket: object, sender_id: str, chat_id: str, instruction: str, option_no: str = "", airline: str = "", flight_no: str = "", ) -> str: """ 航线锁舱。 舱位编号、航司、航班号没齐时只请补全,不调 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" no = ticket_no(ticket) name = operator_name(flow, sender_id) view_quote = dict(ticket_view(ticket).get("quote") or {}) air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {}) quote = air_quote or view_quote quote_id = _quote_id(quote) facts = dict(ticket_view(ticket).get("collab_facts") or {}) picked, air_code, flight = _resolve_lock_params( quote, facts, option_no=option_no, airline=airline, flight_no=flight_no, instruction=instruction, ) missing = _missing_lock_labels(picked, air_code, flight) if missing and _quote_ready_for_lock(quote): _remember_partial_lock( flow, work_order_no=no, sender_id=sender_id, option_no=picked, airline=air_code, flight_no=flight, ) send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(missing)) return "wait_lock_params" 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, airline=air_code, flight_no=flight, ) or {}, is_ok=lambda x: bool((x or {}).get("ok")), ) if not out.get("ok"): if str(out.get("error") or "") == "no_quote": send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no)) return "no_quote" # 主账在出站前发现三项不齐:只请补,不当 TMS 故障,不通知 IT。 reason_text = str(out.get("failReason") or "") if str(out.get("error") or "") == "lock_params_incomplete" or "锁舱参数不完整" in reason_text: labels = missing or _labels_from_incomplete_reason(reason_text) _remember_partial_lock( flow, work_order_no=no, sender_id=sender_id, option_no=picked, airline=air_code, flight_no=flight, ) send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(labels)) return "wait_lock_params" # 多段是产品限制,不是 TMS 故障。只回原因,不标系统异常、不暂停工单。 if str(out.get("error") or "") == "multi_no_lock": send_group_text( group_client_of(flow), chat_id, copy.multi_air_lock_unsupported(work_order_no=no, operator=name), ) return "multi_lock_unsupported" 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, "airline": air_code, "flight_no": flight, "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: "", _PENDING_LOCK_OPTION: "", _PENDING_LOCK_AIRLINE: "", _PENDING_LOCK_FLIGHT: "", }, 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 _release_should_push_deal(ticket: object) -> bool: """ 放舱成功后要不要再推成交跟进。 未成交、已关闭、已成交、转人工:成交结果已经定过,只回释放结果。 还在等销售确认时仍要推一张。 """ return ticket_status(ticket) not in TERMINAL 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) if _release_should_push_deal(ticket): 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 _OPTION_RE = re.compile(r"AIR-?OPT-?\d+") _AIRLINE_AFTER_OPTION_RE = re.compile(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])") _AIRLINE_LABELED_CODE_RE = re.compile( r"航司(?:代码|二字码)?\s*[::]?\s*(?:是|为)?\s*([A-Z]{2}|[A-Z]\d|\d[A-Z])(?![A-Z0-9])" ) _AIRLINE_LABELED_NAME_RE = re.compile( r"航司(?:代码|二字码)?\s*[::]?\s*(?:是|为)?\s*([\u4e00-\u9fffA-Za-z0-9]{2,12})" ) _FLIGHT_LABELED_RE = re.compile( r"(?:航班号|航班)\s*[::]?\s*(?:是|为)?\s*([A-Z]{2}\s*\d{2,4}|[A-Z]\d\s*\d{2,4}|\d[A-Z]\s*\d{2,4})" ) _FLIGHT_TRAIL_RE = re.compile( r"AIR-?OPT-?\d+\s+(?:[A-Z]{2}\s+)?([A-Z]{2}\s*\d{2,4})" ) _FLIGHT_LOOSE_RE = re.compile(r"(? tuple[str, str, str]: """ 从航线原话抽出舱位编号、航司、航班号。 不要求「AIR-OPT01 CZ 航班CZ327」这一种写法。 带「舱位 / 航司 / 航班」的说法、换顺序、航司用中文名,都认。 本地对不上、话里又在补这三项时,再问模型。没说的不猜。 """ option, airline, flight = _lock_params_local(text) if option and airline and flight: return option, airline, flight if not _should_ask_lock_model(text, option, airline, flight): return option, airline, flight got_opt, got_air, got_flight = _lock_params_from_model(text) return ( option or got_opt, airline or _normalize_airline(got_air), flight or _normalize_flight(got_flight), ) def text_has_lock_supplement(text: str) -> bool: """这句话是在补锁舱三项,不是报价或闲聊。凑齐两项,或点名了舱位/航司/航班,都算。""" option, airline, flight = _lock_params_local(text) filled = sum(1 for item in (option, airline, flight) if item) if filled >= 2: return True raw = text or "" if filled < 1 or not any(word in raw for word in ("舱位", "航司", "航班")): return False from agent.routing.group_intent import text_is_staff_fee_quote return not text_is_staff_fee_quote(raw) def _lock_params_local(text: str) -> tuple[str, str, str]: """按原话里的编号、标签和常见航司名收三项。不调模型。""" raw = text or "" blob = raw.upper() option = _option_from_blob(blob) flight = _flight_from_blob(blob) airline = _airline_from_text(raw, blob, option=option, flight=flight) return option, airline, flight def _option_from_blob(blob: str) -> str: found = _OPTION_RE.search(blob or "") if not found: return "" raw = found.group(0) if raw.startswith("AIROPT"): return "AIR-OPT" + raw[6:] return raw def _flight_from_blob(blob: str) -> str: labeled = _FLIGHT_LABELED_RE.search(blob or "") if labeled: return _normalize_flight(labeled.group(1)) trailed = _FLIGHT_TRAIL_RE.search(blob or "") if trailed: return _normalize_flight(trailed.group(1)) loose = _FLIGHT_LOOSE_RE.search(blob or "") if loose: return _normalize_flight(loose.group(1)) return "" def _airline_from_text(raw: str, blob: str, *, option: str, flight: str) -> str: """航司可以是二字码,也可以是「航司南航」这种说法。""" labeled = _AIRLINE_LABELED_CODE_RE.search(blob or "") if labeled: return labeled.group(1) named = _AIRLINE_LABELED_NAME_RE.search(raw or "") if named: token = named.group(1).strip() if token.upper() not in {"AIR", "OPT"} and not token.upper().startswith("AIR-OPT"): return _normalize_airline(token) after = _AIRLINE_AFTER_OPTION_RE.search(blob or "") if after: return after.group(1) # 没写「航司」二字时,只有这句话已经带了舱位或航班,才从航司名里认,避免报价句子被误伤。 if option or flight: found = _alias_in_text(raw) if found: return found return "" def _normalize_flight(value: str) -> str: compact = re.sub(r"\s+", "", (value or "").upper()) return compact if _FLIGHT_CODE_RE.match(compact) else "" def _normalize_airline(value: str) -> str: text = (value or "").strip() if not text: return "" upper = re.sub(r"\s+", "", text.upper()) if _FLIGHT_CODE_RE.match(upper): return "" if _AIRLINE_CODE_RE.match(upper): return upper alias = _alias_in_text(text) if alias: return alias if re.fullmatch(r"[\u4e00-\u9fffA-Za-z0-9]{2,12}", text) and not text.upper().startswith("AIR"): return text return "" def _alias_in_text(text: str) -> str: raw = text or "" for name, code in _AIRLINE_ALIASES: if name in raw: return code return "" def _airline_ready(airline: str) -> bool: text = (airline or "").strip() if _AIRLINE_CODE_RE.match(text.upper()): return True return bool(_normalize_airline(text)) def _should_ask_lock_model(text: str, option: str, airline: str, flight: str) -> bool: """三项已经齐,或这句话根本没在说舱位,就不要再打模型。""" if option and airline and flight: return False raw = text or "" if any(word in raw for word in ("舱位", "航司", "航班")): return True blob = raw.upper() return "AIR-OPT" in blob or "AIROPT" in blob def _lock_params_from_model(text: str) -> tuple[str, str, str]: """ 模型只抽舱位编号、航司、航班号。 网络未开或调用失败时当没抽到,群里继续按缺项追问。不在请求里猜。 """ try: from agent.config import get_settings from agent.llm.http_client import LlmHttpClient from agent.llm.modes import LlmMode settings = get_settings() if not ( getattr(settings, "llm_allow_network", False) and getattr(settings, "llm_data_usage_confirmed", False) ): return "", "", "" client = LlmHttpClient.from_settings(settings) data = client.chat( mode=LlmMode.EXTRACT_FIELDS, messages=[ { "role": "system", "content": ( "从航线这句话抽出锁舱三项。只回复一行 JSON,不要解释。" '键为 optionNo、airline、flightNo。没有的填空字符串。' "optionNo 是舱位编号,如 AIR-OPT01;airline 是航司二字码或航司名;" "flightNo 是航班号,如 CZ327。不要编造原文没有的值。" ), }, {"role": "user", "content": (text or "")[:500]}, ], ) content = str((data or {}).get("content") or "") start = content.find("{") end = content.rfind("}") if start < 0 or end <= start: return "", "", "" payload = json.loads(content[start : end + 1]) if not isinstance(payload, dict): return "", "", "" option = _option_from_blob(str(payload.get("optionNo") or "").upper()) airline = _normalize_airline(str(payload.get("airline") or "")) flight = _normalize_flight(str(payload.get("flightNo") or "")) return option, airline, flight except Exception: logger.exception("锁舱三项模型抽取失败") return "", "", "" def _resolve_lock_params( quote: dict[str, Any], facts: dict[str, Any], *, option_no: str, airline: str, flight_no: str, instruction: str, ) -> tuple[str, str, str]: """ 拼出锁舱三项。 这一句里写的优先;没写的用上一句暂存的;再没有才用报价上已有的舱位。 航司写成南航、东方航空这类名字时归一成二字码;归一不了也算已经补充,不再追问。 """ said_opt, said_air, said_flight = lock_params_from_text(instruction) option = (option_no or said_opt or str(facts.get(_PENDING_LOCK_OPTION) or "")).strip() air = _normalize_airline(airline or said_air or str(facts.get(_PENDING_LOCK_AIRLINE) or "")) flight = _normalize_flight(flight_no or said_flight or str(facts.get(_PENDING_LOCK_FLIGHT) or "")) chosen = _option_row(quote, option) if chosen is None and not option: options = _air_options(quote) if len(options) == 1: chosen = options[0] option = str(chosen.get("optionNo") or chosen.get("option_no") or "").strip() if not _airline_ready(air) and chosen is not None: air = _normalize_airline( str(chosen.get("airlineCode") or chosen.get("airline") or "") ) if not flight and chosen is not None: flight = _normalize_flight(str(chosen.get("flightNo") or chosen.get("flight_no") or "")) if not flight: flight = _normalize_flight(str(quote.get("flightNo") or quote.get("flight_no") or "")) if not _airline_ready(air): air = "" if not air and len(flight) >= 2 and flight[:2].isalpha(): air = flight[:2] return option, air, flight def _option_row(quote: dict[str, Any], option_no: str) -> Optional[dict[str, Any]]: """按舱位编号找报价里的那一条;对不上返回空。""" want = (option_no or "").strip().upper() if not want: return None for row in _air_options(quote): no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper() if no == want: return row return None def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[str]: """缺的中文名,顺序固定:舱位编号、航司、航班号。""" missing: list[str] = [] if not (option_no or "").strip(): missing.append("舱位编号") if not _airline_ready(airline): missing.append("航司") if not _FLIGHT_CODE_RE.match((flight_no or "").strip().upper()): missing.append("航班号") return missing def _labels_from_incomplete_reason(reason: str) -> list[str]: """主账回了「缺少:…」时,按中文名收回缺项,丢掉英文字段名。""" found = [label for label in ("舱位编号", "航司", "航班号") if label in (reason or "")] return found or ["舱位编号", "航司", "航班号"] def _remember_partial_lock( flow: Any, *, work_order_no: str, sender_id: str, option_no: str, airline: str, flight_no: str, ) -> None: """先记下已经说清的项,下一句补齐后接着锁,不用重说。""" patcher = getattr(flow.ledger, "patch_collab_facts", None) if not callable(patcher): return patcher( work_order_no=work_order_no, facts={ _PENDING_LOCK: "1", _PENDING_LOCK_OPTION: option_no, _PENDING_LOCK_AIRLINE: airline, _PENDING_LOCK_FLIGHT: flight_no, }, sender_id=sender_id, ) def _quote_id(quote: dict[str, Any]) -> str: return str(quote.get("quoteId") or quote.get("quote_id") or "").strip() def _quote_ready_for_lock(quote: dict[str, Any]) -> bool: """ 有没有可锁的报价:真实 TMS 编号,或人工总价/费用明细。 与内存账本口径一致,禁止把空报价当成「缺舱位」去补问。 """ src = dict(quote or {}) label = str(src.get("source_label") or src.get("sourceLabel") or "") if "确认" in label and "TMS" in label.upper(): qid = str(src.get("quoteId") or "").strip() return bool(qid) and not qid.upper().startswith("QT-") if str(src.get("total") or "").strip() not in {"", "-"}: return True if src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or src.get("departureCharges"): return True if _air_options(src): return True qid = str(src.get("quoteId") or src.get("quote_id") or "").strip() return bool(qid) and not qid.upper().startswith("QT-WO") def _picked_option( options: list[dict[str, Any]], option_no: str ) -> dict[str, Any]: """按 optionNo 取舱位;对不上返回空 dict。""" want = (option_no or "").strip().upper() if not want: return {} for row in options: no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper() if no == want: return row return {} def _airline_from_quote_or_option( quote: dict[str, Any], options: list[dict[str, Any]], option_no: str, ) -> str: """ 锁舱航司:舱位 airlineCode > 报价顶层 > 航班号前两位。 只回两位大写字母;猜不到就空,交给群里补问。 """ opt = _picked_option(options, option_no) raw = str( opt.get("airlineCode") or opt.get("airline") or quote.get("airlineCode") or quote.get("airline") or "" ).strip().upper() if len(raw) >= 2 and raw[:2].isalpha(): return raw[:2] flight = str( opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or "" ).strip().upper() if len(flight) >= 2 and flight[0].isalpha() and flight[1].isalpha(): return flight[:2] name = str(opt.get("airlineName") or quote.get("airlineName") or "").strip() # 名称里不硬猜二字码,避免「南航」编成乱码。 _ = name return "" def _flight_no_from_quote_or_option( quote: dict[str, Any], options: list[dict[str, Any]], option_no: str, instruction: str = "", ) -> str: """航班号:舱位/报价字段,或句中「航班CZ327」「CZ327」形态。""" import re opt = _picked_option(options, option_no) raw = str(opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or "").strip() if raw: return raw.upper() blob = (instruction or "").upper() m = re.search(r"(?:航班|FLIGHT)\s*([A-Z0-9]{2,8})", blob) if m: return m.group(1) m = re.search(r"\b([A-Z]{2}\d{2,4})\b", blob) if m: return m.group(1) return "" def _chargeable_weight_kg( quote: dict[str, Any], options: list[dict[str, Any]], option_no: str, ticket: object, ) -> str: """ 计费重:舱位 > 报价 > 工单事实「计费重」> 毛重。 有正数才返回;否则空串,门禁会补问。 """ opt = _picked_option(options, option_no) candidates = [ opt.get("chargeableWeightKg") or opt.get("chargeable_weight_kg"), quote.get("chargeableWeightKg") or quote.get("chargeable_weight_kg"), ] view = ticket_view(ticket) facts = dict(view.get("facts") or {}) candidates.extend( [ facts.get("计费重"), facts.get("计费重量"), view.get("chargeableWeightKg"), facts.get("毛重"), view.get("weightKg") or view.get("weight_kg"), ] ) for raw in candidates: text = str(raw or "").strip().replace(",", "") if not text or text in {"-", "null", "None"}: continue # 去掉「公斤」「KGS」等后缀,只留数字。 num = "" for ch in text: if ch.isdigit() or ch == ".": num += ch elif num: break if not num: continue try: if float(num) > 0: return num except ValueError: continue return "" 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