1762 lines
63 KiB
Python
1762 lines
63 KiB
Python
"""
|
||
空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、锁舱/释放、成交跟进。
|
||
|
||
本文件职责:群里副作用(主账 + 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"(?<![A-Z0-9])(?!WO)([A-Z]{2}\d{2,4})(?!\d)")
|
||
_AIRLINE_CODE_RE = re.compile(r"^(?:[A-Z]{2}|[A-Z]\d|\d[A-Z])$")
|
||
_FLIGHT_CODE_RE = re.compile(r"^(?:[A-Z]{2}|[A-Z]\d|\d[A-Z])\d{2,4}$")
|
||
# 口语航司名归一成二字码。认不出的原样留下,交给 TMS,不因此再追问。
|
||
_AIRLINE_ALIASES = (
|
||
("中国南方航空", "CZ"),
|
||
("南方航空", "CZ"),
|
||
("南航", "CZ"),
|
||
("中国国际航空", "CA"),
|
||
("国际航空", "CA"),
|
||
("国航", "CA"),
|
||
("中国东方航空", "MU"),
|
||
("东方航空", "MU"),
|
||
("东航", "MU"),
|
||
("海南航空", "HU"),
|
||
("海航", "HU"),
|
||
("厦门航空", "MF"),
|
||
("厦航", "MF"),
|
||
("深圳航空", "ZH"),
|
||
("深航", "ZH"),
|
||
("四川航空", "3U"),
|
||
("川航", "3U"),
|
||
("山东航空", "SC"),
|
||
("山航", "SC"),
|
||
("春秋航空", "9C"),
|
||
("吉祥航空", "HO"),
|
||
("新加坡航空", "SQ"),
|
||
("新航", "SQ"),
|
||
("泰国航空", "TG"),
|
||
("泰航", "TG"),
|
||
("国泰航空", "CX"),
|
||
("国泰", "CX"),
|
||
("卡塔尔航空", "QR"),
|
||
("顺丰航空", "O3"),
|
||
)
|
||
|
||
|
||
def lock_params_from_text(text: str) -> 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
|