diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index 8946fa2..e182377 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -22,6 +22,8 @@ from agent.policy.air_group_ops import ( apply_air_file, apply_air_lock, apply_air_quote, + lock_params_from_text, + text_has_lock_supplement, apply_air_release, apply_air_tms_choice, current_ticket, @@ -229,10 +231,18 @@ def handle_air_group( if is_handoff_ticket(ticket): logger.info("air_group 转人工不再跟 chat=%s", chat_id) return "handoff_ignore" - from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused + from agent.policy.system_exception import ( + cabin_failure_pause, + reply_paused_ticket, + ticket_is_paused, + ) - # 锁舱失败留下的暂停只拦还要打 TMS 的操作。成交、未成交和补原因不调用 TMS。 - if ticket_is_paused(ticket) and not _deal_ignores_tms_pause(ticket, intent): + # 查价失败的暂停仍要拦住。锁舱/释放失败已经告诉过原因,不能挡住后面的报价和成交。 + if ( + ticket_is_paused(ticket) + and not cabin_failure_pause(ticket) + and not _deal_ignores_tms_pause(ticket, intent) + ): reply_paused_ticket( ticket=ticket, reply=lambda text: send_group_text(group_client_of(engine), chat_id, text), @@ -310,6 +320,29 @@ def _handle_cabin( _ = injected_quote chat_id = message.chat_id if not work_order_no: + # 本群已经在锁这张单时,补舱位不用再报工单号。 + bound = current_ticket(engine, chat_id) + if bound is not None and is_air_ticket(bound): + ticket = bound + if intent == INTENT_RELEASE: + return apply_air_release( + flow=engine, + ticket=ticket, + sender_id=message.sender_id, + chat_id=chat_id, + instruction=message.content or "", + ) + option_no, airline, flight_no = lock_params_from_text(message.content or "") + return apply_air_lock( + flow=engine, + ticket=ticket, + sender_id=message.sender_id, + chat_id=chat_id, + instruction=message.content or "", + option_no=option_no, + airline=airline, + flight_no=flight_no, + ) send_group_text(group_client_of(engine), chat_id, copy.air_lock_need_work_order()) return "cabin_need_work_order" ticket, phase = resolve_cabin_ticket( @@ -328,15 +361,17 @@ def _handle_cabin( chat_id=chat_id, instruction=message.content or "", ) - # 「WO…锁舱,锁AIR-OPT01 TH」要带上句中的舱位和航司,不能只靠报价里碰巧只有一条。 + # 「锁 AIR-OPT01 CZ 航班CZ327」要带上舱位、航司、航班号。缺了先补,不能直接锁。 + option_no, airline, flight_no = lock_params_from_text(message.content or "") return apply_air_lock( flow=engine, ticket=ticket, sender_id=message.sender_id, chat_id=chat_id, instruction=message.content or "", - option_no=_option_from_text(message.content or ""), - airline=_airline_from_text(message.content or ""), + option_no=option_no, + airline=airline, + flight_no=flight_no, ) @@ -373,19 +408,20 @@ def _handle_bound( if lost: return lost facts = dict(ticket_view(ticket).get("collab_facts") or {}) - if intent == INTENT_PICK_OPTION or ( - intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1" - ): - option = _option_from_text(message.content or "") - if option: + pending_lock = str(facts.get("__pending_air_lock") or "") == "1" + # 等补舱位时,报价/选线路等意图让路:话里带了舱位、航司或航班号就继续锁。 + if intent == INTENT_PICK_OPTION or pending_lock or text_has_lock_supplement(message.content or ""): + option_no, airline, flight_no = lock_params_from_text(message.content or "") + if option_no or airline or flight_no: return apply_air_lock( flow=engine, ticket=ticket, sender_id=message.sender_id, chat_id=chat_id, instruction=message.content or "", - option_no=option, - airline=_airline_from_text(message.content or ""), + option_no=option_no, + airline=airline, + flight_no=flight_no, ) if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}: outcome = { @@ -421,6 +457,18 @@ def _handle_bound( sender_id=message.sender_id, chat_id=chat_id, ) + from agent.policy.sea_group_ops import is_sales + from agent.routing.group_intent import resolve_price_intent + + # 航线写出「空运费改为900」是报价。模型若判成调价,这里按说话人改回来。 + priced = resolve_price_intent( + intent, + message.content or "", + sales=is_sales(ticket, message.sender_id), + ) + if priced != intent: + logger.info("air_group 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id) + intent = priced if intent == INTENT_ADJUST: from agent.policy.quote_adjust_ops import apply_group_adjust @@ -448,26 +496,3 @@ def _handle_bound( return "group_ignore" -def _option_from_text(text: str) -> str: - """从「锁AIR-OPT01 TH」抽出舱位编号;不要把后面的航司码拼进 optionNo。""" - import re - - m = re.search(r"AIR-?OPT-?\d+", (text or "").upper()) - if not m: - return "" - raw = m.group(0) - # 统一成 TMS 常见写法 AIR-OPT01(中间可保留已有的第二根横杠) - if raw.startswith("AIROPT"): - return "AIR-OPT" + raw[6:] - return raw - - -def _airline_from_text(text: str) -> str: - """从「AIR-OPT01 TH」抽出两位航司码;没有就不猜。""" - import re - - blob = (text or "").upper() - m = re.search(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])", blob) - if m: - return m.group(1) - return "" diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index 5b93358..fd7b65b 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -126,9 +126,14 @@ def handle_group_inbound( logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id) return "group_ignore" - from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused + from agent.policy.system_exception import ( + cabin_failure_pause, + reply_paused_ticket, + ticket_is_paused, + ) - if ticket_is_paused(ticket): + # 锁舱失败不挡海运/陆运群里的后续报价。查价失败的暂停仍要停。 + if ticket_is_paused(ticket) and not cabin_failure_pause(ticket): reply_paused_ticket( ticket=ticket, reply=lambda text: send_group_text(group_client_of(engine), chat_id, text), @@ -238,6 +243,18 @@ def handle_group_inbound( sender_id=message.sender_id, sess=sess, ) + from agent.policy.sea_group_ops import is_sales + from agent.routing.group_intent import resolve_price_intent + + # 产品写出「报关费改为300」是报价。不是这张单的销售时,不能回调价话术。 + priced = resolve_price_intent( + intent, + message.content or "", + sales=is_sales(ticket, message.sender_id), + ) + if priced != intent: + logger.info("group_inbound 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id) + intent = priced if intent == INTENT_ADJUST: from agent.policy.quote_adjust_ops import apply_group_adjust diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index a14dccc..3ae6365 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -404,6 +404,7 @@ def handle_text_inquiry( return info if ok else "" from agent.policy.system_exception import ( + cabin_failure_pause, extract_work_order_nos, reply_paused_ticket, ticket_is_paused, @@ -420,7 +421,7 @@ def handle_text_inquiry( getter = getattr(ledger, "get_for_agent", None) if callable(getter): ticket = getter(work_order_no=no, sender_id=message.sender_id) - if ticket_is_paused(ticket): + if ticket_is_paused(ticket) and not cabin_failure_pause(ticket): reply_paused_ticket(ticket=ticket, reply=reply) logger.info("text_inquiry 暂停工单只回兜底 wo=%s sender=%s", no, message.sender_id) return "system_exception_paused" diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 5a59331..c603bb9 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -423,7 +423,9 @@ class HttpLedger: operator_name: str = "", instruction_text: str = "", airline: str = "", + flight_no: str = "", ) -> dict[str, Any]: + """锁舱出站。航班号和航司一并带上,主账缺了这两项不能当锁舱失败。""" body: dict[str, Any] = { "workOrderNo": work_order_no, "optionNo": option_no, @@ -433,10 +435,12 @@ class HttpLedger: } if (airline or "").strip(): body["airline"] = airline.strip().upper() + if (flight_no or "").strip(): + body["flightNo"] = flight_no.strip().upper() return self._post( "/inquiry/agent/tms/lockCabin", body, - idem=f"air-lock:{work_order_no}:{option_no}", + idem=f"air-lock:{work_order_no}:{option_no}:{(airline or '').strip()}:{(flight_no or '').strip()}", ) def release_cabin( diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index 0e415e1..9e84f8c 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -827,9 +827,10 @@ class MemoryLedger: operator_name: str = "", instruction_text: str = "", airline: str = "", + flight_no: str = "", ) -> dict[str, Any]: """单测可注入 _cabin_lock_result;默认成功。""" - _ = sender_id, operator_name, instruction_text, airline + _ = sender_id, operator_name, instruction_text, airline, flight_no forced = getattr(self, "_cabin_lock_result", None) if isinstance(forced, dict): return dict(forced) diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 6962fe9..0dbaf26 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -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"(? 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() diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index e491cb5..c98e441 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -1260,13 +1260,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 +1304,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, @@ -1976,7 +2002,12 @@ class AirTextInquiryFlow: sess.phase = "done" sess.allowed = () self._save(sess) - reply(copy.lost_reason_ack(str(result.get("lost_reason") or text))) + reply( + copy.lost_reason_ack( + str(result.get("lost_reason") or text), + work_order_no=sess.work_order_no, + ) + ) return "done" def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None: diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index f7e0cba..ae262c3 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -1219,10 +1219,10 @@ def sea_quote_detail_text( def collab_hint(work_order_no: str) -> str: - """价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。""" + """空运价格卡协同说明:请把相关方拉进原有协同群,并 @ 询价小助手。紧挨生成报价单按钮上方。""" return ( - f"若需要咨询内部航线人员,请手动拉新人与智能体到原有群," - f"并在群聊发送工单编号{work_order_no}。" + f"请手动将相关方拉到空运原有协同群,并在空运协同群中发送" + f"“ {work_order_no}@询价小助手”,谢谢。" ) @@ -2723,7 +2723,7 @@ def deal_ack( if outcome == "未成交": reason = (lost_reason or "").strip() if reason: - return f"已记录为「未成交」。{lost_reason_ack(reason)}" + return f"已记录为「未成交」。{lost_reason_ack(reason, work_order_no=no)}" return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no) when = _followup_month_day(next_followup_at) or "第四个工作日" return _r( @@ -2750,14 +2750,20 @@ def _followup_month_day(raw: str) -> str: return "" -def lost_reason_ack(reason: str = "") -> str: - """收下未成交原因后回显原句。后台 lost.reason_ack。""" +def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str: + """ + 收下未成交原因后的回执。后台 lost.reason_ack。 + + 后台模板里的 {{lost_reason}}、{{ticket_no}} 都在这里填。 + 不传工单号时占位留空串,避免群里再露出 {{ticket_no}}。 + """ text = (reason or "").strip() + no = (work_order_no or "").strip() if text.endswith(("。", ".", "!", "!", "?", "?")): default = f"已记下未成交原因:{text}" else: default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。" - return _r("lost.reason_ack", default, lost_reason=text) + return _r("lost.reason_ack", default, lost_reason=text, ticket_no=no) def deal_notify_card( @@ -2901,7 +2907,8 @@ def air_group_brief( 空运群激活摘要:与海运进群同一套结构,字段用空运。 先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。 - TMS 段跟单聊同一套线路分项,总销售价只一行;禁止用「空运费=总价」冒充。 + TMS 段跟单聊同一套线路分项,总销售价只一行。 + 已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。 不写「当前工单/询价要点/现在等到哪一步」。 """ no = (work_order_no or "").strip() @@ -3028,6 +3035,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() diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index c0f710f..66f1d4c 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -1337,7 +1337,11 @@ def capture_group_lost_reason( sess.phase = "done" sess.allowed = () flow._save(sess) - send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text))) + send_group_text( + client, + chat_id, + copy.lost_reason_ack(str(result.get("lost_reason") or text), work_order_no=no), + ) return "done" diff --git a/inquiry-agent/agent/policy/system_exception.py b/inquiry-agent/agent/policy/system_exception.py index 4a53cf2..399b17e 100644 --- a/inquiry-agent/agent/policy/system_exception.py +++ b/inquiry-agent/agent/policy/system_exception.py @@ -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 @@ -867,6 +896,8 @@ def _resume_cabin_step( option_no=option_no, sender_id=sender, instruction_text=instruction, + airline=str(extra.get("airline") or ""), + flight_no=str(extra.get("flight_no") or ""), ) or {} if callable(locker) diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py index 8db5c8e..0f43168 100644 --- a/inquiry-agent/agent/routing/air_group_intent.py +++ b/inquiry-agent/agent/routing/air_group_intent.py @@ -26,6 +26,8 @@ from agent.routing.group_intent import ( _hint_confirm_tms, _hint_deal_outcome, _hint_tms_choice, + _prefer_staff_quote, + text_is_staff_fee_quote, ) logger = logging.getLogger(__name__) @@ -95,6 +97,11 @@ def classify_air_group_text( locked = _hint_lock_or_release(raw) if locked: return locked + # 补舱位不限写法。凑齐舱位编号、航司、航班号就当选舱,不要先当成报价。 + from agent.policy.air_group_ops import text_has_lock_supplement + + if text_has_lock_supplement(raw): + return INTENT_PICK_OPTION # 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。 exact = _exact_tms_choice(raw) if exact: @@ -118,12 +125,15 @@ def classify_air_group_text( if use_net and getattr(settings, "llm_data_usage_confirmed", False): got = _classify_with_model(raw, settings) if got in _VALID: - return got + return _prefer_staff_quote(got, raw) except Exception: logger.exception("空运群意图模型失败,回退规则") from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote from agent.schema.quote_adjust import extract_adjust + # 费用金额先当航线报价。「空运费改为900」不是销售调价。 + if text_is_staff_fee_quote(raw): + return INTENT_QUOTE if extract_adjust(raw).is_adjust: return INTENT_ADJUST if extract_product_quote(raw): @@ -176,10 +186,10 @@ def _classify_with_model(text: str, settings) -> str: "你是空运协同群意图分类。只回复一个英文词,不要解释。" "air_lock=航线要锁舱;air_release=航线要释放舱位;" "air_activate=只是指定/切换工单号;" - "air_pick_option=在选舱位选项编号;" + "air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;" "collab_fields=在补是否含电/是否含磁;" - "quote_adjust=销售在调已有费用,口令不写死(上升、往上抬、加一成、改到某金额都算),不是新产品报价;" - "product_quote=航线在报价格或费用(含具体金额);" + "quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;" + "product_quote=航线在报某一项费用的金额。写出「空运费9900」「空运费改为900USD」是报价,不是调价;" "quote_confirm_tms=确认沿用当前TMS报价;" "quote_keep_tms=要在已有TMS报价上改;" "quote_drop_tms=不要TMS、只用刚报的价;" @@ -188,7 +198,7 @@ def _classify_with_model(text: str, settings) -> str: "other=闲聊。" "一句话里既有锁舱又有工单号,回 air_lock。" "一句话里既有工单号又确认成交/未成交/协商中,回 deal_won/deal_lost/deal_negotiate,不要回 air_activate。" - "改已有费用优先 quote_adjust;新报价格才是 product_quote。" + "有具体费用金额就是 product_quote。只有比例或只改总价才是 quote_adjust。" ) data = client.chat( mode=LlmMode.ORDERED_ACTIONS, diff --git a/inquiry-agent/agent/routing/group_intent.py b/inquiry-agent/agent/routing/group_intent.py index 25cea54..2689381 100644 --- a/inquiry-agent/agent/routing/group_intent.py +++ b/inquiry-agent/agent/routing/group_intent.py @@ -117,6 +117,63 @@ _DROP_HINTS = ( ) +def text_is_staff_fee_quote(text: str) -> bool: + """ + 航线/产品在报某一项费用的金额。 + + 「空运费 9900」「空运费改为900USD」「报关费改为300CNY」都算手工报价。 + 比例调价和只改总价/总销售价不算,那两句仍留给销售调价。 + 句里没有费用名、只剩一个数字,也不算,避免把选项编号当成报价。 + 不打模型。分类和入站改判都会用。 + """ + raw = (text or "").strip() + if not raw: + return False + from agent.schema.quote_adjust import text_has_relative_adjust, text_has_total_only_adjust + + if text_has_relative_adjust(raw) or text_has_total_only_adjust(raw): + return False + from agent.llm.extract_text import extract_product_quote + + quote = extract_product_quote(raw) + rows = [row for row in (quote.get("fee_rows") or []) if isinstance(row, dict)] + if not rows: + return False + # 「费用合计」里的「费」不能单独把改总价认成报价;上面已经排除只改总价。 + if "费" in raw: + return True + names = [str(row.get("name") or "").strip() for row in rows] + return any(name and name != "海运费" for name in names) + + +def resolve_price_intent(intent: str, text: str, *, sales: bool) -> str: + """ + 按说话人把「报价」和「调价」分开。 + + 航线/产品写出费用金额:按报价办,不能回「只有销售能调价」。 + 销售在做比例、改总价,或点名把某项费用改到某个金额:仍按调价办。 + 不打模型。群入站分发前调用,避免手工报价被调价话术拦住。 + """ + if intent not in {INTENT_ADJUST, INTENT_QUOTE}: + return intent + from agent.schema.quote_adjust import extract_adjust + + # 这里只用本地规则。正式调价抽取仍在调价处理里,必要时再问模型。 + if sales and extract_adjust(text, allow_model=False).is_adjust: + return INTENT_ADJUST + if text_is_staff_fee_quote(text): + return INTENT_QUOTE + return intent + + +def _prefer_staff_quote(intent: str, text: str) -> str: + """模型或本地规则把费用金额判成调价时,改回报价。比例和改总价不动。""" + if intent == INTENT_ADJUST and text_is_staff_fee_quote(text): + logger.info("判成调价,但这是费用报价,改回报价 text=%s", (text or "")[:40]) + return INTENT_QUOTE + return intent + + def classify_group_text( text: str, *, @@ -163,12 +220,15 @@ def classify_group_text( if hinted_deal and got in {INTENT_DISSOLVE, INTENT_OTHER, INTENT_FIELDS}: logger.warning("群意图模型把成交判成%s,改回 %s text=%s", got, hinted_deal, raw[:40]) return hinted_deal - return got + return _prefer_staff_quote(got, raw) except Exception: logger.exception("群意图模型失败,回退标签字段") from agent.llm.extract_text import extract_product_quote from agent.schema.quote_adjust import extract_adjust + # 费用金额先当报价。「改为900」是航线/产品常用写法,不能先被调价规则抢走。 + if text_is_staff_fee_quote(raw): + return INTENT_QUOTE if extract_adjust(raw).is_adjust: return INTENT_ADJUST if extract_product_quote(raw): @@ -249,8 +309,8 @@ def _classify_with_model(text: str, settings) -> str: system = ( "你是海运协同群意图分类。只回复一个英文词,不要解释。" "collab_fields=在补货好时间/商品海关编码/含油含电含磁;" - "quote_adjust=销售在调已有费用,口令不写死(上升、上浮、往上抬、加一成、减、改到某金额都算),不是新产品报价;" - "product_quote=产品在报价格或费用(含具体金额);" + "quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;" + "product_quote=产品在报某一项费用的金额。写出「运费1000」「报关费改为300CNY」是报价,不是调价;" "quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;" "quote_keep_tms=要在已有TMS报价上改,没有报新金额;" "quote_drop_tms=不要TMS、只用产品刚报的价,没有报新金额;" @@ -260,7 +320,8 @@ def _classify_with_model(text: str, settings) -> str: "dissolve=要把这个协同群解散;" "other=闲聊、问候、对询价港口的随口更正(不当改单)。" "一句话里既补字段又报价,回 product_quote。" - "有金额就是 product_quote,不要判成 quote_keep_tms。" + "有具体费用金额就是 product_quote,不要判成 quote_adjust 或 quote_keep_tms。" + "只有比例或只改总价才是 quote_adjust。" ) data = client.chat( mode=LlmMode.ORDERED_ACTIONS, diff --git a/inquiry-agent/agent/schema/air_route_options.py b/inquiry-agent/agent/schema/air_route_options.py index dea0ad2..871e41c 100644 --- a/inquiry-agent/agent/schema/air_route_options.py +++ b/inquiry-agent/agent/schema/air_route_options.py @@ -45,10 +45,55 @@ def parse_air_options(quote: dict[str, Any] | None) -> list[dict[str, Any]]: def needs_route_pick(quote: dict[str, Any] | None) -> bool: - """多于一条线路才进 H5 选。""" + """没写起运地、且多于一条线路,才进 H5 选。有起运地时由调用方先按起运地收口。""" return len(parse_air_options(quote)) > 1 +def options_matching_origin( + options: list[dict[str, Any]], + origin_text: str, +) -> list[dict[str, Any]]: + """ + 销售已经写了起运地时,只留起运机场对得上的线路。 + + 杭州对 HGH,珠海对 ZUH。回包里的南京/深圳/郑州不能拿来给这张单选。 + 对不上返回空列表。不改入参。 + """ + text = (origin_text or "").strip() + if not text: + return list(options) + code = resolve_airport_code(text).upper() + raw = text.upper() + matched: list[dict[str, Any]] = [] + for opt in options: + origin = str(opt.get("originAirportCode") or opt.get("origin") or "").strip().upper() + if not origin: + continue + if code and origin == code: + matched.append(opt) + elif origin == raw: + matched.append(opt) + return matched + + +def choose_stated_origin_option( + options: list[dict[str, Any]], + quote: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ + 起运地已指定时直接用一条,不再让销售选线。 + + 只有一条就用它。多条时优先和报价总价相同的那条,否则用第一条。 + """ + if len(options) == 1: + return options[0] + total = (quote or {}).get("total") + for opt in options: + if _same_amount(opt.get("salePrice"), total): + return opt + return options[0] + + def _place_name(code: str, fallback: str = "") -> str: """三字码能对上就用中文,否则用原文或 fallback。""" raw = (code or "").strip() @@ -331,14 +376,14 @@ def _same_amount(left: Any, right: Any) -> bool: def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]: """ - 整单兜底「空运费=总价」不是线路分项,群摘要禁止再写。 + 已经有其它费用分项时,去掉「空运费=总价」这一行兜底。 - 真有空运费且金额不等于总价(例如另有附加费)则保留。 + 舱位上有运输费、提送货费时,主账快照常再塞一行空运费,金额就是总价,不能和分项叠在一起。 + 只有空运费这一项时必须留下:TMS 经常只回这一项,金额自然等于总价,群里仍要写出费用名。 + 不写库。群摘要投影之后调用。 """ src = dict(quote or {}) total = src.get("total") - if _same_amount(src.get("freight"), total): - src["freight"] = "-" raw = src.get("fee_lines") or src.get("fee_rows") parsed: Any = raw if isinstance(raw, str) and raw.strip(): @@ -348,17 +393,26 @@ def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any] parsed = None if not isinstance(parsed, list): return src - kept: list[Any] = [] + others: list[Any] = [] + alias: list[Any] = [] for item in parsed: if not isinstance(item, dict): - kept.append(item) + others.append(item) continue - name = str(item.get("name") or item.get("label") or "").strip() + name = str(item.get("name") or item.get("label") or item.get("feeName") or "").strip() amount = item.get("amount") or item.get("value") if name == "空运费" and _same_amount(amount, total): + alias.append(item) continue - kept.append(item) - src["fee_lines"] = kept + others.append(item) + if others: + # 分项已经在,空运费=总价是重复兜底。运费字段同值也不再补一行。 + src["fee_lines"] = others + if _same_amount(src.get("freight"), total): + src["freight"] = "-" + return src + # 明细就是这一条空运费,群摘要要显示费用名,不能只剩费用合计。 + src["fee_lines"] = alias return src diff --git a/inquiry-agent/agent/schema/quote_adjust.py b/inquiry-agent/agent/schema/quote_adjust.py index a376f51..e6fdd52 100644 --- a/inquiry-agent/agent/schema/quote_adjust.py +++ b/inquiry-agent/agent/schema/quote_adjust.py @@ -493,6 +493,40 @@ def effective_quote(tms: dict[str, Any] | None, overlay: dict[str, Any] | None) return out +def text_has_relative_adjust(text: str) -> bool: + """ + 这句话是在按比例改价:加10%、减5%、上调、上浮、加一成。 + + 这种是销售调价,不是航线/产品报一个新金额。不打模型。 + """ + raw = text or "" + if _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw): + return True + blob = raw.replace(" ", "").replace(" ", "") + return any(phrase in blob for phrase in ("加一成", "减一成", "往上抬", "往下压", "上浮", "下浮")) + + +def text_has_total_only_adjust(text: str) -> bool: + """ + 只动总价/总销售价/费用合计,没有点名某一项费用。 + + 「总销售价改为180」仍是销售调价。点了「空运费」「报关费」则不是。不打模型。 + """ + raw = text or "" + if not (_TARGET.search(raw) or _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw)): + return False + if not any(word in raw for word in _TOTAL_WORDS) and "报价" not in raw: + return False + real: list[str] = [] + for name in _FEE_TOKEN.findall(raw): + if name in {"费用", "费用合计"}: + continue + if any(word in name for word in ("合计", "总价", "汇总")): + continue + real.append(name) + return not real + + def extract_adjust( text: str, *, diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py index d21adfd..afba3c6 100644 --- a/inquiry-agent/agent/schema/tms_air_query.py +++ b/inquiry-agent/agent/schema/tms_air_query.py @@ -35,6 +35,9 @@ _AIRPORTS = { "南京": "NKG", "吉隆坡": "KUL", "北京": "PEK", + "杭州": "HGH", + "萧山": "HGH", + "杭州萧山": "HGH", } # 历史字典:只给对照,空运出站不再编成 1/2/3。 @@ -199,7 +202,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: 包装只看销售是否说了,散货等原文原样保留。 """ src = dict(facts or {}) - origin = str(src.get("起运港") or "").strip() + origin = str(src.get("起运港") or src.get("起运地") or "").strip() dest = str(src.get("目的港") or "").strip() cargo = str(src.get("品名") or "").strip() pieces = _first_number(str(src.get("件数") or "")) diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 1b26094..b21912a 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -160,7 +160,7 @@ class AirGroupCollabTests(unittest.TestCase): "source_label": "TMS 标准报价", "quoteId": "QT-AIR-1", "airOptions": [ - {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航"} + {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航", "flightNo": "CZ327"} ], } if quote: @@ -203,6 +203,67 @@ class AirGroupCollabTests(unittest.TestCase): self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK) self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE) + def test_airline_fee_wording_is_quote_not_adjust(self) -> None: + """航线写「空运费改为900」是手工报价,不能先判成调价。比例和改总价仍是调价。""" + from agent.routing.air_group_intent import INTENT_ADJUST, INTENT_QUOTE + + self.assertEqual( + classify_air_group_text("空运费改为900USD", allow_network=False), + INTENT_QUOTE, + ) + self.assertEqual( + classify_air_group_text("报关费改为300CNY", allow_network=False), + INTENT_QUOTE, + ) + self.assertEqual( + classify_air_group_text("空运费 9900CNY", allow_network=False), + INTENT_QUOTE, + ) + self.assertEqual( + classify_air_group_text("空运费加10%", allow_network=False), + INTENT_ADJUST, + ) + self.assertEqual( + classify_air_group_text("总销售价改为180", allow_network=False), + INTENT_ADJUST, + ) + + def test_airline_rewritten_fee_does_not_say_only_sales(self) -> None: + """已有报价后,航线再写费用金额,不能提示只有销售能调价。""" + wo = self._create_air() + handle_air_group( + self._msg(sender="air1", text=wo, chat="wr_air_1", mid="fee0"), + flow=self.flow, + ledger=self.ledger, + ) + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_1", mid="fee1"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertNotEqual(phase, "adjust_not_sales") + self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts())) + self.assertIn(phase, {"wait_tms_choice", "quoted"}) + + def test_sales_percent_still_adjusts(self) -> None: + """销售说加百分之几,仍是调价,不会被改成航线报价。""" + wo = self._create_air() + handle_air_group( + self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="adj0"), + flow=self.flow, + ledger=self.ledger, + ) + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="sales1", text="空运费加10%", chat="wr_air_1", mid="adj1"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertNotEqual(phase, "not_airline") + self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts())) + self.assertIn(phase, {"group_adjusted", "adjust_need_fee", "adjust_none", "adjust_need_lines"}) + def test_air_group_brief_matches_sea_sections(self) -> None: text = copy.air_group_brief( work_order_no="WO202609160009", @@ -373,6 +434,51 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("费用合计:CNY 200040", tms_block) self.assertEqual(tms_block.count("费用合计:"), 1) + def test_air_group_brief_keeps_sole_air_freight_line(self) -> None: + """TMS 只有空运费、金额等于总价时,群摘要必须写出这一行,不能只剩费用合计。""" + tms = { + "source": "TMS", + "source_label": "TMS标准报价", + "total": "1833.7", + "freight": "1833.70", + "currency": "CNY", + "valid_until": "2026-10-21", + "fee_lines": '[{"name":"空运费","amount":"CNY 1833.70"}]', + "feeItems": [ + { + "feeName": "空运费", + "feeCode": "AIR_FREIGHT", + "amount": 1833.70, + "unitPrice": 11.00, + "quantity": 166.7, + "currency": "CNY", + } + ], + "airOptions": [ + { + "originAirportCode": "CGO", + "destinationAirportCode": "KUL", + "salePrice": 1833.70, + "currency": "CNY", + "departureCharges": [], + } + ], + } + text = copy.air_group_brief( + work_order_no="WO202610080020", + facts={"起运地": "CGO", "目的地": "吉隆坡", "originCode": "CGO"}, + quote=tms, + collab_facts={}, + has_price=True, + tms_quote=tms, + ) + tms_block = text.split("TMS报价:", 1)[1] + self.assertIn("空运费:CNY 1833.70", tms_block) + self.assertLess(tms_block.find("空运费:"), tms_block.find("费用合计:")) + self.assertIn("费用合计:CNY 1833.7", tms_block) + self.assertEqual(tms_block.count("费用合计:"), 1) + self.assertEqual(tms_block.count("费用合计:"), 1) + def test_work_order_plus_lost_reason_is_deal_not_activate(self) -> None: """WO@助手 未成交,原因… 必须当关单,不能只激活。""" from agent.routing.air_group_intent import INTENT_ACTIVATE, INTENT_LOST @@ -960,9 +1066,147 @@ class AirGroupCollabTests(unittest.TestCase): ledger=self.ledger, injected_intent="air_lock", ) + self.assertEqual(phase, "wait_lock_params") + phase = handle_air_group( + self._msg( + sender="air1", + text="AIR-OPT01 CZ 航班CZ327", + chat="wr_air_lock_manual", + mid="lm2", + ), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_pick_option", + ) self.assertEqual(phase, "lock_ok") self.assertTrue(any("执行成功" in t for t in self._texts())) + def test_lock_missing_params_asks_and_does_not_call_tms(self) -> None: + """缺舱位、航司、航班号时只请补,不锁舱、不报 TMS 失败、不通知 IT。""" + wo = self._create_air( + quote={ + "total": "CNY 1800", + "source": "TMS", + "source_label": "TMS 标准报价", + "quoteId": "QT-AIR-23", + } + ) + calls: list[dict] = [] + orig = self.ledger.lock_cabin + + def spy(**kwargs): + calls.append(kwargs) + return orig(**kwargs) + + self.ledger.lock_cabin = spy # type: ignore + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_ask", mid="ask1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "wait_lock_params") + self.assertEqual(calls, []) + asked = self._texts()[-1] + self.assertEqual( + asked, + "锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。", + ) + self.assertNotIn("已通知", asked) + self.assertNotIn("锁舱失败", asked) + phase = handle_air_group( + self._msg( + sender="air1", + text="AIR-OPT01 CZ 航班CZ327", + chat="wr_air_lock_ask", + mid="ask2", + ), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_pick_option", + ) + self.assertEqual(phase, "lock_ok") + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].get("option_no"), "AIR-OPT01") + self.assertEqual(calls[0].get("airline"), "CZ") + self.assertEqual(calls[0].get("flight_no"), "CZ327") + + def test_lock_accepts_free_wording_not_only_sample_format(self) -> None: + """补舱位不限格式。点名舱位、航司、航班号,换顺序、用航司中文名,也要锁。""" + wo = self._create_air( + quote={ + "total": "CNY 1800", + "source": "TMS", + "source_label": "TMS 标准报价", + "quoteId": "QT-AIR-24", + } + ) + calls: list[dict] = [] + orig = self.ledger.lock_cabin + + def spy(**kwargs): + calls.append(kwargs) + return orig(**kwargs) + + self.ledger.lock_cabin = spy # type: ignore + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_free", mid="free1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "wait_lock_params") + self.assertEqual(calls, []) + phase = handle_air_group( + self._msg( + sender="air1", + text="航班号:CZ327,航司南航,舱位编号是AIR-OPT01", + chat="wr_air_lock_free", + mid="free2", + ), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "lock_ok") + self.assertEqual(calls[0].get("option_no"), "AIR-OPT01") + self.assertEqual(calls[0].get("airline"), "CZ") + self.assertEqual(calls[0].get("flight_no"), "CZ327") + + def test_lock_sentence_with_labeled_params_locks_immediately(self) -> None: + """同一句里已经说齐三项,直接锁,不再要求按示例格式重说。""" + wo = self._create_air( + quote={ + "total": "CNY 1800", + "source": "manual", + "source_label": "产品报价", + } + ) + calls: list[dict] = [] + orig = self.ledger.lock_cabin + + def spy(**kwargs): + calls.append(kwargs) + return orig(**kwargs) + + self.ledger.lock_cabin = spy # type: ignore + phase = handle_air_group( + self._msg( + sender="air1", + text=f"{wo}锁舱,舱位 AIR-OPT02,东方航空,航班号 MU5123", + chat="wr_air_lock_once", + mid="once1", + ), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "lock_ok") + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].get("option_no"), "AIR-OPT02") + self.assertEqual(calls[0].get("airline"), "MU") + self.assertEqual(calls[0].get("flight_no"), "MU5123") + self.assertFalse(any("锁舱参数不完整" in t for t in self._texts())) + def test_lock_success_and_release_after_deal_blocked(self) -> None: wo = self._create_air() phase = handle_air_group( @@ -1049,8 +1293,8 @@ class AirGroupCollabTests(unittest.TestCase): "source": "TMS", "quoteId": "QT-AIR-2", "airOptions": [ - {"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}, - {"optionNo": "AIR-OPT-02", "airlineCode": "MU"}, + {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"}, + {"optionNo": "AIR-OPT-02", "airlineCode": "MU", "flightNo": "MU5123"}, ], } ) @@ -1060,7 +1304,7 @@ class AirGroupCollabTests(unittest.TestCase): ledger=self.ledger, injected_intent="air_lock", ) - self.assertEqual(phase, "wait_lock_option") + self.assertEqual(phase, "wait_lock_params") phase = handle_air_group( self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"), flow=self.flow, @@ -1155,6 +1399,31 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("请确认成交状态", texts) self.assertFalse(any(x.get("kind") == "file" for x in added)) + def test_reactivate_quoted_tms_file_is_deal_not_sheet(self) -> None: + """TMS 价的报价单已经发出、停在成交跟进:再发工单号只回成交跟进。""" + wo = self._create_air( + quote={ + "total": "CNY 3500.70", + "source": "TMS", + "source_label": "TMS 标准报价", + "quoteId": "QT-AIR-FILE", + "quoteFileIssued": True, + "airOptions": [ + {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"} + ], + } + ) + phase = handle_air_group( + self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="react-deal"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "activated_deal") + texts = "\n".join(self._texts()) + self.assertIn("请确认成交状态", texts) + self.assertNotIn("询价字段:", texts) + self.assertFalse(any(x.get("kind") == "file" for x in self.groups.sent)) + def test_image_rejected(self) -> None: wo = self._create_air() handle_air_group( @@ -1385,6 +1654,38 @@ class AirGroupCollabTests(unittest.TestCase): self.assertNotIn("报价选项不存在", joined) self.assertNotIn("与工单不匹配", joined) + def test_lock_fail_does_not_block_airline_quote(self) -> None: + """锁舱失败后,航线再报手工价必须收下,不能回 TMS 接口异常。""" + wo = self._create_air() + handle_air_group( + self._msg(sender="air1", text=wo, chat="wr_air_lock_quote", mid="lq0"), + flow=self.flow, + ledger=self.ledger, + ) + self.ledger._cabin_lock_result = { + "ok": False, + "error": "lock_fail", + "failReason": "该航班日期不存在可锁定的配载记录", + } + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_quote", mid="lq1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "system_exception") + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_lock_quote", mid="lq2"), + flow=self.flow, + ledger=self.ledger, + injected_intent="product_quote", + ) + self.assertNotEqual(phase, "system_exception_paused") + joined = "\n".join(self._texts()) + self.assertNotIn("TMS接口异常", joined) + self.assertIn(phase, {"wait_tms_choice", "quoted"}) + def test_lock_without_quote_still_shows_tms_reason(self) -> None: """TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。""" wo = self._create_air(quote={}, status="询价中") diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py index 856a97e..0c6ffd1 100644 --- a/inquiry-agent/tests/test_air_route_options.py +++ b/inquiry-agent/tests/test_air_route_options.py @@ -106,6 +106,82 @@ class AirRouteOptionsTests(unittest.TestCase): self.assertEqual(sess.facts.get("起运港"), "深圳") self.assertIn("SZX-CRK", "\n".join(replies[-2:])) + def test_hangzhou_query_uses_airport_and_skips_other_cities(self) -> None: + """写了杭州就不能按目的地把南京、深圳、郑州拿来选。""" + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy.air_text_flow import AirTextInquiryFlow + from agent.schema.tms_air_query import assemble_air_query + + ready = assemble_air_query({"起运港": "杭州", "目的港": "吉隆坡"}) + self.assertEqual(ready["facts"].get("originCode"), "HGH") + self.assertEqual(ready["facts"].get("destinationCode"), "KUL") + + class Ledger(MemoryLedger): + def query_tms(self, **kwargs): + out = super().query_tms(**kwargs) + quote = out["quote"] + quote["total"] = "1750.35" + quote["currency"] = "CNY" + quote["airOptions"] = [ + {"originAirportCode": "NKG", "destinationAirportCode": "KUL", "salePrice": 1750.35, "currency": "CNY"}, + {"originAirportCode": "SZX", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"}, + {"originAirportCode": "CGO", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"}, + ] + return out + + flow = AirTextInquiryFlow(Ledger()) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + facts = { + "起运港": "杭州", + "目的港": "吉隆坡", + "品名": "化妆品", + "件数": "12", + "毛重": "21", + "体积": "1", + "包装方式": "散货", + } + self.assertEqual( + flow.on_text(sender_id="u_hgh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"), + "wait_confirm", + ) + phase = flow.on_text(sender_id="u_hgh", text="确定", reply=reply) + self.assertEqual(phase, "tms_miss") + self.assertFalse(any("条线路" in x for x in replies)) + self.assertEqual(flow.session_of("u_hgh").facts.get("originCode"), "HGH") + + def test_stated_origin_quotes_directly(self) -> None: + """起运地已经是珠海时,即使 TMS 还带回别的城市,也不再让销售选线。""" + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy.air_text_flow import AirTextInquiryFlow + + flow = AirTextInquiryFlow(MemoryLedger()) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + facts = { + "起运港": "珠海", + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "托盘", + "tms_multi": "1", + } + flow.on_text(sender_id="u_zuh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR") + phase = flow.on_text(sender_id="u_zuh", text="确定", reply=reply) + self.assertEqual(phase, "wait_collab") + self.assertFalse(any("条线路" in x for x in replies)) + self.assertEqual(flow.session_of("u_zuh").facts.get("起运港"), "珠海") + def test_reselect_after_adopt_voids_old_file(self) -> None: """采用后再改选:旧报价单作废,回到方案卡重新采用。""" ledger = MemoryLedger() diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 7503290..3900739 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -900,6 +900,10 @@ class AirTextInquiryTests(unittest.TestCase): self.assertNotIn("引用历史工单号", sub) self.assertNotIn("工单状态", sub) self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001"))) + self.assertIn( + "请手动将相关方拉到空运原有协同群,并在空运协同群中发送“ WO202609140001@询价小助手”,谢谢。", + sub, + ) self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False)) def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None: diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py index 18fbd8d..5884642 100644 --- a/inquiry-agent/tests/test_deal_outcome.py +++ b/inquiry-agent/tests/test_deal_outcome.py @@ -148,6 +148,26 @@ class TestDealOutcome(unittest.TestCase): self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。") self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。") + def test_lost_reason_ack_fills_ticket_no(self) -> None: + """后台模板带工单号时,必须换成真实单号,不能把占位符原样发出去。""" + from agent.policy.bot_reply import override_catalog + + override_catalog( + { + "lost.reason_ack": ( + "已将未成交原因“{{lost_reason}}”记录在后台," + "工单{{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。" + ) + } + ) + try: + self.assertEqual( + copy.lost_reason_ack("客户不需要了", work_order_no="WO202609100001"), + "已将未成交原因“客户不需要了”记录在后台,工单WO202609100001已关闭,智能体不再跟进该工单,请悉知。", + ) + finally: + override_catalog(None) + def test_harvest_inline_lost_reason(self) -> None: self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了") self.assertEqual( diff --git a/inquiry-agent/tests/test_land_group_collab.py b/inquiry-agent/tests/test_land_group_collab.py index 2bca8ce..bde13c6 100644 --- a/inquiry-agent/tests/test_land_group_collab.py +++ b/inquiry-agent/tests/test_land_group_collab.py @@ -317,6 +317,31 @@ class LandGroupCollabTests(unittest.TestCase): ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.status, "已报价") + def test_product_fee_change_is_quote_not_adjust(self) -> None: + """产品写「报关费改为300」是手工报价。已有报价后也不能被拦成只有销售能调价。""" + from agent.routing.group_intent import classify_group_text + + self.assertEqual(classify_group_text("报关费改为300CNY", allow_network=False), INTENT_QUOTE) + self.assertEqual(classify_group_text("运费加 10%", allow_network=False), INTENT_ADJUST) + wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件") + handle_group_collab( + self._msg(sender="p_dom", text="运费 1000", mid="q_fee0"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_QUOTE, + injected_quote={"total": "1000", "source_label": "产品报价"}, + ) + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="p_dom", text="报关费改为300CNY", mid="q_fee1"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertNotEqual(phase, "adjust_not_sales") + self.assertNotIn("只有这张单的销售能调价", self._group_texts()) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertTrue(ticket.quote) + def test_product_cannot_adjust(self) -> None: self._open(option_text="国内运输拼车 国内长途/零担 急件") self.groups.sent.clear() diff --git a/inquiry-agent/tests/test_system_exception.py b/inquiry-agent/tests/test_system_exception.py index ae60708..3133d99 100644 --- a/inquiry-agent/tests/test_system_exception.py +++ b/inquiry-agent/tests/test_system_exception.py @@ -480,6 +480,11 @@ class TestConfirmSystemException(unittest.TestCase): self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined) def test_admin_retry_cabin_lock_runs_lock(self) -> None: + self.ledger.upsert_quote( + work_order_no=self.wo, + quote={"total": "100", "quoteId": "QT-AIR-1"}, + to_status="已报价", + ) self.ledger.mark_system_exception( work_order_no=self.wo, system_exception=TYPE_TMS, diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java index 73c0aa2..3572242 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java @@ -207,6 +207,26 @@ public class InquiryTmsCabinService { req.put("requestedUntil", text(body, "requestedUntil")); } } + // 航线口令里的航班号盖过报价。三项没齐就地拦住,不去调 TMS 锁舱。 + String flightFromCaller = firstNonBlank( + text(body, "flightNo", "flight_no"), + flightNoFromInstruction(text(body, "instructionText", "instruction_text"))); + if (StringUtils.isNotBlank(flightFromCaller)) { + req.put("flightNo", flightFromCaller); + } + if (StringUtils.isBlank(text(req, "airline"))) { + String fromFlight = airlineFromFlight(text(req, "flightNo")); + if (StringUtils.isNotBlank(fromFlight)) { + req.put("airline", fromFlight); + } + } + String incomplete = missingLockParamReason( + text(req, "optionNo"), text(req, "airline"), text(req, "flightNo")); + if (StringUtils.isNotBlank(incomplete)) { + log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={}", + no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo")); + return fail("lock_params_incomplete", incomplete); + } return invokeLock(ticket, req, quoteId, resolvedOption); } @@ -432,6 +452,14 @@ public class InquiryTmsCabinService { req.remove("quoteId"); } fillLockQuoteExtras(req, quote, option); + if (StringUtils.isBlank(text(req, "validUntil"))) { + // 航线手工价经常不写有效期。TMS 人工锁舱必填 validUntil, + // 用工单报价日(调用前已放进 flightDate)的当天结束,不再空着出站。 + String iso = normalizeValidUntilIso(text(req, "flightDate")); + if (StringUtils.isNotBlank(iso)) { + req.put("validUntil", iso); + } + } String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"), text(quote, "transferAirport", "transferAirportCode")); if (StringUtils.isNotBlank(transfer)) { @@ -1049,6 +1077,50 @@ public class InquiryTmsCabinService { return row; } + /** + * 锁舱三项没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。 + * 三项都缺时是:锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。 + */ + static String missingLockParamReason(String optionNo, String airline, String flightNo) { + java.util.List missing = new ArrayList<>(); + if (StringUtils.isBlank(optionNo)) { + missing.add("舱位编号"); + } + if (StringUtils.isBlank(airline)) { + missing.add("航司"); + } + if (StringUtils.isBlank(flightNo)) { + missing.add("航班号"); + } + if (missing.isEmpty()) { + return ""; + } + return "锁舱参数不完整,缺少:" + String.join("、", missing) + + "。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。"; + } + + /** + * 从「AIR-OPT01 CZ 航班CZ327」抽出航班号。没有就不猜。 + */ + static String flightNoFromInstruction(String instruction) { + if (StringUtils.isBlank(instruction)) { + return ""; + } + java.util.regex.Matcher named = java.util.regex.Pattern + .compile("(?i)(?:航班号|航班)\\s*[::]?\\s*(?:是|为)?\\s*([A-Za-z]{2}\\s*\\d{2,4})") + .matcher(instruction); + if (named.find()) { + return named.group(1).toUpperCase(); + } + java.util.regex.Matcher trailed = java.util.regex.Pattern + .compile("(?i)AIR-?OPT-?\\d+\\s+(?:[A-Za-z]{2}\\s+)?([A-Za-z]{2}\\d{2,4})") + .matcher(instruction); + if (trailed.find()) { + return trailed.group(1).toUpperCase(); + } + return ""; + } + /** * 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json index 2b236de..e2204c2 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json @@ -13,7 +13,13 @@ {"source_name": "北京首都", "source_code": "PEK"}, {"source_name": "北京大兴", "source_code": "PKX"}, {"source_name": "珠海", "source_code": "ZUH"}, - {"source_name": "克拉克", "source_code": "CRK"} + {"source_name": "克拉克", "source_code": "CRK"}, + {"source_name": "南京", "source_code": "NKG"}, + {"source_name": "吉隆坡", "source_code": "KUL"}, + {"source_name": "北京", "source_code": "PEK"}, + {"source_name": "杭州", "source_code": "HGH"}, + {"source_name": "萧山", "source_code": "HGH"}, + {"source_name": "杭州萧山", "source_code": "HGH"} ], "air_packaging_type": [ {"source_name": "全部", "source_code": "1"}, diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java index 9c97ef1..1b32e47 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java @@ -197,6 +197,34 @@ public class InquiryTmsCabinServiceTest { assertEquals("空运费", fees.get(0).get("feeName")); } + @Test + public void manualLockFillsValidUntilFromFlightDateWhenQuoteHasNone() { + Map req = new HashMap<>(); + req.put("flightDate", "2026-10-11"); + InquiryTmsCabinService.fillManualLock(req, new HashMap<>(), new HashMap<>(), new HashMap<>(), false); + assertEquals("2026-10-11T23:59:59+08:00", req.get("validUntil")); + assertEquals("2026-10-11T23:59:59+08:00", req.get("requestedUntil")); + } + + @Test + public void missingLockParamsAsksWithoutCallingTmsWording() { + assertEquals( + "锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。", + InquiryTmsCabinService.missingLockParamReason("", "", "")); + assertEquals( + "锁舱参数不完整,缺少:航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。", + InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "")); + assertEquals("", InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327")); + } + + @Test + public void flightNoFromInstructionReadsSampleFormat() { + assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction( + "AIR-OPT01 CZ 航班CZ327")); + assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction("AIR-OPT01 CZ CZ327")); + assertEquals("", InquiryTmsCabinService.flightNoFromInstruction("WO202610080023锁舱")); + } + @Test public void airlineFromInstructionReadsCodeAfterOption() { assertEquals("TH", InquiryTmsCabinService.airlineFromInstruction( diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java index 0698c37..bfd4260 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java @@ -32,4 +32,11 @@ public class TmsExactProjectorTest { assertTrue(hit.ok()); assertEquals("SHA", hit.code); } + + @Test + public void hangzhouMapsToXiaoshan() { + TmsExactProjector.Projection hit = projector.resolveAirport("杭州"); + assertTrue(hit.ok()); + assertEquals("HGH", hit.code); + } }