空运协同里手工改费用、有起运地、锁舱失败或缺参会被误判,报价单已出后再激活又重发模板。按销售侧口径收紧这些分支,并让价格卡和未成交回执带上真实工单号。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,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
|
||||
@@ -43,6 +44,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"
|
||||
@@ -116,7 +120,7 @@ def activate_ticket(
|
||||
把本群当前工单切到指定空运未结案单。
|
||||
|
||||
返回 (工单, phase)。失败时工单为 None。
|
||||
航线已经用附件或文字落过价时,再发工单号只回到成交跟进,不再发空白模板。
|
||||
已经在成交跟进,或航线/产品价已经落账时,再发工单号只回到成交跟进,不再发工单摘要和模板。
|
||||
"""
|
||||
book = flow.ledger
|
||||
no = (work_order_no or "").strip()
|
||||
@@ -174,23 +178,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 {"", "-", "—", "-"}
|
||||
@@ -817,30 +843,45 @@ def apply_air_lock(
|
||||
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"
|
||||
# 锁舱是航线主动再试:即使工单因上次失败标了系统异常,也要再打 TMS,
|
||||
# 不能只回「TMS接口异常,IT运维排查中」。
|
||||
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
|
||||
options = _air_options(quote)
|
||||
quote_id = _quote_id(quote)
|
||||
picked = (option_no or "").strip()
|
||||
if not picked and len(options) > 1:
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
if str(facts.get(_PENDING_LOCK) or "") != "1":
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
|
||||
)
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
|
||||
return "wait_lock_option"
|
||||
if not picked and len(options) == 1:
|
||||
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
|
||||
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(
|
||||
@@ -864,7 +905,8 @@ def apply_air_lock(
|
||||
sender_id=sender_id,
|
||||
operator_name=name,
|
||||
instruction_text=instruction,
|
||||
airline=(airline or "").strip(),
|
||||
airline=air_code,
|
||||
flight_no=flight,
|
||||
)
|
||||
or {},
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
@@ -873,6 +915,20 @@ def apply_air_lock(
|
||||
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(
|
||||
@@ -903,6 +959,8 @@ def apply_air_lock(
|
||||
conversation_target=chat_id,
|
||||
extra={
|
||||
"option_no": picked,
|
||||
"airline": air_code,
|
||||
"flight_no": flight,
|
||||
"instruction": instruction,
|
||||
"sender_id": sender_id,
|
||||
"operator_name": name,
|
||||
@@ -922,6 +980,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,
|
||||
)
|
||||
@@ -1168,6 +1229,350 @@ 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 _quote_ready_for_lock(quote: dict[str, Any]) -> bool:
|
||||
"""有总价、费用或报价编号才值得让航线补舱位。完全没报价仍走「还没有报价」。"""
|
||||
if str(quote.get("total") or "").strip() not in {"", "-"}:
|
||||
return True
|
||||
if quote.get("fee_lines") or quote.get("feeItems") or quote.get("departureCharges"):
|
||||
return True
|
||||
if _air_options(quote):
|
||||
return True
|
||||
return bool(str(quote.get("quoteId") or quote.get("quote_id") or "").strip())
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user