合并 feat/continue-deal-card-20261009:报价发出后继续工单重发成交卡;空运锁舱补参与看板滚动一并收下。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-09 09:06:43 +08:00
co-authored by Cursor
50 changed files with 2173 additions and 320 deletions
+417 -90
View File
@@ -11,6 +11,7 @@ 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
@@ -44,6 +45,9 @@ 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"
@@ -117,7 +121,7 @@ def activate_ticket(
把本群当前工单切到指定空运未结案单。
返回 (工单, phase)。失败时工单为 None。
航线已经用附件或文字落过价时,再发工单号只回到成交跟进,不再发空白模板。
已经在成交跟进,或航线/产品价已经落账时,再发工单号只回到成交跟进,不再发工单摘要和模板。
"""
book = flow.ledger
no = (work_order_no or "").strip()
@@ -175,23 +179,45 @@ def activate_ticket(
def _air_already_quoted_for_deal(ticket: object) -> bool:
"""
航线报价已经落账,再激活应回成交跟进。
再激活应回成交跟进,而不是重发工单摘要和空运模板。
附件报价会把来源标成「航线附件报价」,但报价编号仍可能留着 TMS。
只看 source 会误当成还没报价。还只有 TMS 标准价时仍发模板。
报价单已经发给销售(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
quote = dict(ticket_view(ticket).get("quote") or {})
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 {"", "-", "—", "-"}
@@ -842,80 +868,45 @@ def apply_air_lock(
instruction: str,
option_no: str = "",
airline: str = "",
flight_no: str = "",
) -> str:
"""
空运群锁舱。
航线锁舱。
副作用:调主账 lockCabin(HMAC 出站 TMS);成功写协同 facts、推成交跟进。
舱位/航司/航班交给主账现拉 TMS 补全;仅计费重全空时群里补问。
主账仍缺则回 lock_incomplete,禁止空壳出站。
舱位编号、航司、航班号没齐时只请补全,不调 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"
# 锁舱是航线主动再试:即使工单因上次失败标了系统异常,也要再打 TMS,
# 不能只回「TMS接口异常,IT运维排查中」。
no = ticket_no(ticket)
name = operator_name(flow, sender_id)
view = ticket_view(ticket)
view_quote = dict(view.get("quote") or {})
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
# 最新价常是人工覆盖:舱位/航司/航班要从本单 TMS 原价自动补,不能逼航线手打。
tms_quote = copy.resolve_tms_quote(None, view)
if not tms_quote:
tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
quote_options = _air_options(quote)
tms_options = _air_options(tms_quote)
options = quote_options or tms_options
quote_id = _quote_id(quote) or _quote_id(tms_quote)
picked = (option_no or "").strip()
if not picked and len(options) > 1:
facts = dict(view.get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
)
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
return "wait_lock_option"
if not picked and len(options) == 1:
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
# 没有任何可锁报价时先拒绝,不要把「无报价」误说成缺舱位。
if not _quote_ready_for_lock(quote) and not _quote_ready_for_lock(tms_quote):
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
airline_code = (airline or "").strip().upper()
if not airline_code:
airline_code = _airline_from_quote_or_option(quote, quote_options, picked)
if not airline_code:
airline_code = _airline_from_quote_or_option(tms_quote, tms_options, picked)
chargeable = _chargeable_weight_kg(quote, quote_options, picked, ticket)
if not chargeable:
chargeable = _chargeable_weight_kg(tms_quote, tms_options, picked, ticket)
flight_no = _flight_no_from_quote_or_option(quote, quote_options, picked, instruction)
if not flight_no:
flight_no = _flight_no_from_quote_or_option(tms_quote, tms_options, picked, instruction)
# 舱位/航司/航班交给主账:库里无 TMS 时会现拉补全;仍缺才回 lock_incomplete。
# 这里只拦计费重全空(工单毛重也没有)——否则会逼航线手打航班。
need_chargeable = not chargeable
if need_chargeable:
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
)
send_group_text(
group_client_of(flow),
chat_id,
copy.air_ask_manual_lock_fields(
need_option=False,
need_airline=False,
need_chargeable=True,
need_flight=False,
options=options,
),
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,
)
return "wait_lock_fields"
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(
@@ -932,14 +923,6 @@ def apply_air_lock(
confirm_system_exception,
)
def _lock_terminal(x: object) -> bool:
"""成功,或业务上不必再重试(缺舱位/无报价)。"""
row = x if isinstance(x, dict) else {}
if bool(row.get("ok")):
return True
err = str(row.get("error") or "")
return err in {"lock_incomplete", "no_quote"}
out = call_with_retries(
lambda: locker(
work_order_no=no,
@@ -947,30 +930,38 @@ def apply_air_lock(
sender_id=sender_id,
operator_name=name,
instruction_text=instruction,
airline=airline_code,
flight_no=flight_no,
airline=air_code,
flight_no=flight,
)
or {},
is_ok=_lock_terminal,
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"
if str(out.get("error") or "") == "lock_incomplete":
# 主账现拉仍无舱位:业务补问,不算系统异常,不惊动 IT。
why = str(out.get("failReason") or out.get("error") or "锁舱参数不完整")
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
)
# 主账在出站前发现三项不齐:只请补,不当 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.air_lock_incomplete(work_order_no=no, operator=name, reason=why),
copy.multi_air_lock_unsupported(work_order_no=no, operator=name),
)
return "lock_incomplete"
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),
@@ -993,7 +984,8 @@ def apply_air_lock(
conversation_target=chat_id,
extra={
"option_no": picked,
"airline": airline_code,
"airline": air_code,
"flight_no": flight,
"instruction": instruction,
"sender_id": sender_id,
"operator_name": name,
@@ -1013,6 +1005,9 @@ def apply_air_lock(
_LOCK_QUOTE: quote_no,
_LOCK_EXEC: exec_no,
_PENDING_LOCK: "",
_PENDING_LOCK_OPTION: "",
_PENDING_LOCK_AIRLINE: "",
_PENDING_LOCK_FLIGHT: "",
},
sender_id=sender_id,
)
@@ -1036,7 +1031,6 @@ def apply_air_lock(
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
return "lock_ok"
def _release_should_push_deal(ticket: object) -> bool:
"""
放舱成功后要不要再推成交跟进。
@@ -1259,6 +1253,339 @@ def _is_product_quote(quote: dict[str, Any]) -> bool:
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()
+33 -4
View File
@@ -882,8 +882,11 @@ class AirTextInquiryFlow:
reply(copy.quote_generating())
return "wait_file"
if sess.phase == "wait_deal":
# 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。
# 原话写了已成交/未成交/协商中时,由 _close_spoken_deal 先入账,不会走到这里。
# 成交卡原来挂在报价单那条消息上,聊天一上滑就找不到。
# 销售再说继续工单、又没写成交结果时,再发一次成交/未成交/协商中。
# 原话已经写了结果时,_close_spoken_deal 会先入账,不会走到这里。
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
self._emit_deal(reply, sess)
return "wait_deal"
if sess.phase == "wait_lost_reason":
reply(copy.deal_ack("未成交", sess.work_order_no))
@@ -1260,13 +1263,39 @@ class AirTextInquiryFlow:
quote = tms["quote"]
from agent.schema.air_route_options import (
choose_stated_origin_option,
needs_route_pick,
options_matching_origin,
parse_air_options,
project_selected_quote,
)
options = parse_air_options(quote)
if options and not needs_route_pick(quote):
stated_origin = str(
(sess.facts or {}).get("起运港") or (sess.facts or {}).get("起运地") or ""
).strip()
# 写了起运地:只认这个起运地的线路,直接报价,不让销售再选。
# 没写起运地:TMS 按目的地回多条时,才进选线页。
if stated_origin:
matched = options_matching_origin(options, stated_origin)
if options and not matched:
logger.info(
"air 起运地对不上回包线路 origin=%s wo=%s options=%s",
stated_origin,
no,
len(options),
)
self._emit_tms_miss(reply, no)
sess.phase = "tms_miss"
sess.status = "询价中"
sess.allowed = ()
self._save(sess)
return "tms_miss"
if matched:
quote = project_selected_quote(
quote, choose_stated_origin_option(matched, quote)
)
elif options and not needs_route_pick(quote):
quote = project_selected_quote(quote, options[0])
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"):
@@ -1278,7 +1307,7 @@ class AirTextInquiryFlow:
copy.remember_tms_quote(sess, quote)
sess.tms_quote = dict(quote)
sess.status = "已报价"
if needs_route_pick(quote):
if not stated_origin and needs_route_pick(quote):
return self._offer_air_routes(
sess=sess,
work_order_no=no,
+3 -1
View File
@@ -46,6 +46,7 @@ _HOLIDAYS = frozenset(
date(2026, 9, 25),
date(2026, 9, 26),
date(2026, 9, 27),
# 国庆只休到 10 月 7 日。10 月 8 日周四正常上班,不能算假期。
date(2026, 10, 1),
date(2026, 10, 2),
date(2026, 10, 3),
@@ -53,7 +54,6 @@ _HOLIDAYS = frozenset(
date(2026, 10, 5),
date(2026, 10, 6),
date(2026, 10, 7),
date(2026, 10, 8),
# 2027 元旦预留
date(2027, 1, 1),
date(2027, 1, 2),
@@ -68,6 +68,8 @@ _MAKEUP_WORKDAYS = frozenset(
date(2026, 2, 14),
date(2026, 2, 28),
date(2026, 5, 9),
# 国庆调休:9 月 20 日周日、10 月 10 日周六上班。
date(2026, 9, 20),
date(2026, 10, 10),
}
)
+43 -1
View File
@@ -164,7 +164,7 @@ def pick_mentions(
"""
本单这次应回而未回、需要被点名的人。
航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售。
航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售,不点航线。
不要询价机器人、不要群里其他人。
"""
kind = (wait_kind or "").strip()
@@ -206,6 +206,48 @@ def pick_mentions(
return out
def mentions_for_role(
rows: list[dict[str, Any]] | None,
*,
role_code: str,
) -> list[dict[str, Any]]:
"""
从员工行里取出启用、有企微、有姓名的指定岗位。
空运群激活只记群号,协同成员经常是空的。超时巡检在群成员里找不到航线岗时,
用本函数点名员工管理里的航线·空运,和进群摘要 @ 的是同一批人。
不发消息、不写主账。
"""
want = (role_code or "").strip().lower()
if not want:
return []
out: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in rows or []:
if not isinstance(raw, dict):
continue
role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower()
if role != want:
continue
status = str(raw.get("status") or "active").strip().lower()
if status in {"disabled", "0", "停用", "inactive"}:
continue
uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip()
name = str(raw.get("name") or "").strip()
if not uid or not name or uid in seen:
continue
seen.add(uid)
out.append(
{
"wecomId": uid,
"name": name,
"roleCode": want,
"notifyEventException": raw.get("notifyEventException", 0),
}
)
return out
def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
"""勾了「接收事件异常消息提醒」且绑了企微的人。"""
out: list[dict[str, Any]] = []
+59 -8
View File
@@ -2400,8 +2400,14 @@ def adjust_ineligible(reason: str) -> str:
return "当前工单还没有可调的报价,不能按这套调价。"
def adjust_only_sales() -> str:
return "只有这张单的销售能调价。航线/产品请继续报价,价格未改。"
def adjust_only_sales(*, air_room: bool = False) -> str:
"""
群里不是这张单的销售在发调价。价格不改。
空运群称呼航线;海运、陆运称呼产品。两句用句号分开,避免连成一句。
"""
who = "航线" if air_room else "产品"
return f"只有这张单的销售能调价。{who}请继续报价,价格未改。"
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
@@ -2778,9 +2784,10 @@ def _followup_month_day(raw: str) -> str:
def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str:
"""
收下未成交原因后回显。后台 lost.reason_ack。
收下未成交原因后的回执。后台 lost.reason_ack。
测服文案含 {{ticket_no}} / {{lost_reason}};缺 ticket_no 会原样打出占位符。
后台模板里的 {{lost_reason}}、{{ticket_no}} 都在这里填。
不传工单号时占位留空串,避免群里再露出 {{ticket_no}}。
"""
text = (reason or "").strip()
no = (work_order_no or "").strip()
@@ -2932,7 +2939,8 @@ def air_group_brief(
空运群激活摘要:与海运进群同一套结构,字段用空运。
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
TMS 段跟单聊同一套线路分项,总销售价只一行;禁止用「空运费=总价」冒充。
TMS 段跟单聊同一套线路分项,总销售价只一行。
已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。
不写「当前工单/询价要点/现在等到哪一步」。
"""
no = (work_order_no or "").strip()
@@ -3059,6 +3067,23 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str:
return air_lock_need_quote(work_order_no)
def air_lock_need_params(missing=None) -> str:
"""
舱位编号、航司、航班号没齐时,只请航线补全。
这时还没锁舱,不能说 TMS 锁舱失败,也不能通知 IT。
缺哪项就点哪项;三项都缺时是产品指定的那一句。
"""
labels = [str(x).strip() for x in (missing or []) if str(x).strip()]
if not labels:
labels = ["舱位编号", "航司", "航班号"]
joined = "、".join(labels)
return (
f"锁舱参数不完整,缺少:{joined}。"
"请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。"
)
def air_lock_need_quote(work_order_no: str) -> str:
"""没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。"""
no = (work_order_no or "").strip()
@@ -3155,6 +3180,32 @@ def air_lock_ok(
)
# 多段里有空运段时,航线锁舱的失败原因。产品暂不支持,不当 TMS 故障。
MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱"
# 后台锁舱失败模板末尾常有「已通知IT…请稍等」。多段这条要整句拿掉。
_IT_NOTICE_LINE = re.compile(r"已通知\s*IT[^\n。]*。?")
def multi_air_lock_unsupported(*, work_order_no: str, operator: str) -> str:
"""
多段含空运、航线锁舱时的群回复。
抬头、操作人、失败原因仍走锁舱失败模板,只去掉通知 IT 那句。
不打主账、不标系统异常。
"""
text = air_lock_fail(
work_order_no=work_order_no,
operator=operator,
reason=MULTI_LOCK_UNSUPPORTED,
)
kept: list[str] = []
for line in text.splitlines():
cleaned = _IT_NOTICE_LINE.sub("", line).strip()
if cleaned:
kept.append(cleaned)
return "\n".join(kept).strip()
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
"""空运锁舱失败(接口/系统类)。后台 inquiring.air_lock_fail。"""
why = (reason or "").strip() or "TMS未返回原因"
@@ -3359,7 +3410,7 @@ def system_exception_alert_payload(
"""
系统异常通知卡:走询价消息服务,不走询价小助手。
五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。
五行:异常类型、接口/服务、报错原因、影响范围、发生时间。
标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。
"""
no = (work_order_no or "").strip() or "无"
@@ -3368,7 +3419,7 @@ def system_exception_alert_payload(
rows = [
{"keyname": "异常类型", "value": (exception_type or "").strip() or "-"},
{"keyname": "接口/服务", "value": (service or "").strip() or "-"},
{"keyname": "错误码", "value": (error_code or "").strip() or "-"},
{"keyname": "报错原因", "value": (error_code or "").strip() or "-"},
{"keyname": "影响范围", "value": system_exception_impact(step)},
{"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"},
]
@@ -3409,7 +3460,7 @@ def system_exception_alert_text(
f"关联工单 {no}",
f"异常类型:{(exception_type or '').strip() or '-'}",
f"接口/服务:{(service or '').strip() or '-'}",
f"错误码:{(error_code or '').strip() or '-'}",
f"报错原因:{(error_code or '').strip() or '-'}",
f"影响范围:{system_exception_impact(step)}",
f"发生时间:{(occurred_at or '').strip() or '-'}",
]
+45 -9
View File
@@ -11,7 +11,8 @@ from __future__ import annotations
import json
import logging
from typing import Any, Callable, Optional
from contextlib import contextmanager
from typing import Any, Callable, Iterator, Optional
from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy import inquiry_copy as copy
@@ -20,6 +21,7 @@ from agent.policy.sea_group_ops import (
apply_group_deal,
group_client_of,
is_sales,
override_group_client,
send_filled_sheet,
send_group_text,
ticket_no,
@@ -33,6 +35,24 @@ _ROLE_MODE = {"sea": "SEA", "land": "LAND", "air": "AIR"}
SheetFn = Callable[..., dict[str, Any]]
@contextmanager
def bot_sender_when_air_multi(ticket: object) -> Iterator[None]:
"""
含空运的多段用的是机器人原来的群。应用接口发不进这个群,报价和模板都会没反应。
这一次出站改走机器人桥,发完还原。没有空运段的多段仍用应用自己建的群,这里不动。
只影响当前线程,不改流程上缓存的客户端。
"""
if not includes_air(load_segments(ticket)):
yield
return
from agent.channel.aibot.reply import AibotReplyClient
logger.info("multi.outbound 含空运走机器人桥 wo=%s", ticket_no(ticket))
with override_group_client(AibotReplyClient()):
yield
def ticket_is_multi(ticket: object) -> bool:
"""主账业务线是多段联运。"""
view = ticket_view(ticket)
@@ -257,8 +277,17 @@ def _save_segment_quote(
def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, intent: str) -> str:
"""空运段的锁舱、释放走现有空运规则,不挡住其他段报价。"""
from agent.policy.air_group_ops import apply_air_lock, apply_air_release
"""
多段含空运时的锁舱、释放。
锁舱暂不支持:只回复失败原因,不打 TMS,也不标系统异常。
释放仍走空运规则。调用方已确认本单含空运段。群消息在 Worker 线程发出。
"""
from agent.policy.air_group_ops import (
apply_air_release,
is_airline,
operator_name,
)
if intent == "release":
return apply_air_release(
@@ -268,13 +297,20 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i
chat_id=chat_id,
instruction=text,
)
return apply_air_lock(
flow=flow,
ticket=ticket,
sender_id=sender_id,
chat_id=chat_id,
instruction=text,
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)
send_group_text(
group_client_of(flow),
chat_id,
copy.multi_air_lock_unsupported(
work_order_no=no,
operator=operator_name(flow, sender_id),
),
)
logger.info("multi.lock unsupported wo=%s sender=%s", no, sender_id)
return "multi_lock_unsupported"
def _cabin_intent(text: str) -> str:
@@ -260,7 +260,14 @@ def apply_group_adjust(
send_group_text(client, room, copy.adjust_ineligible("no_quote"))
return "adjust_blocked"
if not is_sales(ticket, sender_id):
send_group_text(client, room, copy.adjust_only_sales())
line = str(view.get("business_line") or view.get("businessLine") or "").upper()
air_room = line == "AIR"
if line == "MULTI":
from agent.policy.multi_group_ops import load_segments
from agent.policy.multi_segments import includes_air
air_room = includes_air(load_segments(ticket))
send_group_text(client, room, copy.adjust_only_sales(air_room=air_room))
return "adjust_not_sales"
current = _current_quote(view, sess)
names = [str(r.get("name") or "") for r in adjustable_rows(current)]
+31 -6
View File
@@ -9,7 +9,9 @@ from __future__ import annotations
import json
import logging
from typing import Any, Optional
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Iterator, Optional
from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy.quote_overlay import overlay_labeled_facts
@@ -244,14 +246,40 @@ def filename_is_excel_pdf(name: str) -> bool:
return any(raw.endswith(ext) for ext in EXCEL_PDF)
# 这一次群出站临时改用的客户端。只在当前线程有效,不改流程单例上的企微客户端。
_outbound_client: ContextVar[Any] = ContextVar("inquiry_group_outbound_client", default=None)
def group_client_of(flow: Any) -> Any:
"""正式路径没有注入客户端时,走流程自己的默认企微客户端。"""
"""
取这一次群出站用的客户端。
有临时指定时用指定的(含空运的多段要走机器人桥)。
没有时走流程自己的客户端;正式路径默认是应用发群。
"""
override = _outbound_client.get()
if override is not None:
return override
getter = getattr(flow, "group_client", None)
if callable(getter):
return getter()
return getattr(flow, "_group_client", None)
@contextmanager
def override_group_client(client: Any) -> Iterator[None]:
"""
这一次群出站改用指定客户端,用完还原。
只影响当前线程。不改流程单例,海运自己建的群不会被别的工单抢走客户端。
"""
token = _outbound_client.set(client)
try:
yield
finally:
_outbound_client.reset(token)
def _archive_seat_ids(flow: Any) -> set[str]:
"""会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。"""
seats: set[str] = {"AI001", "询价机器人"}
@@ -1317,10 +1345,7 @@ def capture_group_lost_reason(
send_group_text(
client,
chat_id,
copy.lost_reason_ack(
str(result.get("lost_reason") or text),
work_order_no=no,
),
copy.lost_reason_ack(str(result.get("lost_reason") or text), work_order_no=no),
)
return "done"
@@ -95,6 +95,35 @@ def ticket_is_paused(ticket: Any) -> bool:
return bool(ticket_exception_type(ticket))
def ticket_pause_step(ticket: Any) -> str:
"""暂停是哪一步留下的。查价、锁舱、释放各算各的。"""
if ticket is None:
return ""
if isinstance(ticket, dict):
step = str(
ticket.get("system_exception_step") or ticket.get("systemExceptionStep") or ""
).strip()
payload = ticket.get("system_exception_payload") or ticket.get("systemExceptionPayload") or {}
else:
step = str(getattr(ticket, "system_exception_step", "") or "").strip()
payload = getattr(ticket, "system_exception_payload", {}) or {}
if step:
return step
if isinstance(payload, dict):
return str(payload.get("step") or "").strip()
return ""
def cabin_failure_pause(ticket: Any) -> bool:
"""
锁舱或释放失败留下的暂停。
群里已经回过失败原因,并通知了 IT。之后的手工报价、补字段、成交不能再被
「TMS接口异常」挡住。查价失败的暂停不在这里,那种仍要停。
"""
return ticket_pause_step(ticket) in {STEP_TMS_LOCK, STEP_TMS_RELEASE, STEP_TMS_CABIN}
def extract_work_order_nos(text: str) -> list[str]:
"""从原话抽出正式工单号(WO + 日期流水)。不认虚拟号。"""
import re