合并 feat/continue-deal-card-20261009:报价发出后继续工单重发成交卡;空运锁舱补参与看板滚动一并收下。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -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 '-'}",
|
||||
]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user