空运「货交」只记提货地址,不再当成起运地。舱位旧说法「释放」先问航线是否取消,陆运通关口岸示例改为友谊关。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -53,6 +53,8 @@ from agent.routing.air_group_intent import (
|
|||||||
INTENT_PICK_OPTION,
|
INTENT_PICK_OPTION,
|
||||||
INTENT_QUOTE,
|
INTENT_QUOTE,
|
||||||
INTENT_RELEASE,
|
INTENT_RELEASE,
|
||||||
|
INTENT_RELEASE_ASK,
|
||||||
|
cabin_confirm_answer,
|
||||||
classify_air_group_text,
|
classify_air_group_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -167,6 +169,9 @@ def handle_air_group(
|
|||||||
return "duplicate_inbound"
|
return "duplicate_inbound"
|
||||||
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text)
|
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text)
|
||||||
wo = copy.extract_work_order_no(text)
|
wo = copy.extract_work_order_no(text)
|
||||||
|
answered = _answer_pending_cabin_cancel(engine, message, text)
|
||||||
|
if answered:
|
||||||
|
return answered
|
||||||
# 句中有多段工单号,或本群已经激活过多段单:跟这张单,不必每句再带工单号。
|
# 句中有多段工单号,或本群已经激活过多段单:跟这张单,不必每句再带工单号。
|
||||||
followed = _follow_activated_multi(
|
followed = _follow_activated_multi(
|
||||||
engine,
|
engine,
|
||||||
@@ -200,7 +205,7 @@ def handle_air_group(
|
|||||||
intent,
|
intent,
|
||||||
wo or "-",
|
wo or "-",
|
||||||
)
|
)
|
||||||
if intent in {INTENT_LOCK, INTENT_RELEASE}:
|
if intent in {INTENT_LOCK, INTENT_RELEASE, INTENT_RELEASE_ASK}:
|
||||||
return _handle_cabin(
|
return _handle_cabin(
|
||||||
engine,
|
engine,
|
||||||
message,
|
message,
|
||||||
@@ -309,6 +314,92 @@ def _should_speak_activate(intent: str) -> bool:
|
|||||||
return intent in {INTENT_ACTIVATE, INTENT_OTHER}
|
return intent in {INTENT_ACTIVATE, INTENT_OTHER}
|
||||||
|
|
||||||
|
|
||||||
|
def _answer_pending_cabin_cancel(engine, message: InboundMessage, text: str) -> str:
|
||||||
|
"""
|
||||||
|
上一句已经问过是否取消舱位。
|
||||||
|
|
||||||
|
航线回是/需要/确认/取消舱位才执行。回否/不用/不需要就停。
|
||||||
|
销售回这些词不算。说了别的业务就清掉询问,按那句话继续办。
|
||||||
|
没有待确认时返回空串。
|
||||||
|
"""
|
||||||
|
from agent.policy.air_group_ops import is_airline
|
||||||
|
from agent.redis_coord.pending_cabin_cancel import (
|
||||||
|
clear_pending_cabin_cancel,
|
||||||
|
load_pending_cabin_cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_id = (message.chat_id or "").strip()
|
||||||
|
pending_wo = load_pending_cabin_cancel(chat_id)
|
||||||
|
if not pending_wo:
|
||||||
|
return ""
|
||||||
|
kind = cabin_confirm_answer(text)
|
||||||
|
if not kind:
|
||||||
|
clear_pending_cabin_cancel(chat_id)
|
||||||
|
logger.info("cabin.ask dropped chat=%s wo=%s", chat_id, pending_wo)
|
||||||
|
return ""
|
||||||
|
if not is_airline(engine, message.sender_id):
|
||||||
|
send_group_text(group_client_of(engine), chat_id, copy.air_airline_only_cabin())
|
||||||
|
return "not_airline"
|
||||||
|
clear_pending_cabin_cancel(chat_id)
|
||||||
|
if kind == "no":
|
||||||
|
send_group_text(group_client_of(engine), chat_id, copy.air_release_ask_dropped())
|
||||||
|
return "release_ask_dropped"
|
||||||
|
ticket, phase = resolve_cabin_ticket(
|
||||||
|
flow=engine,
|
||||||
|
chat_id=chat_id,
|
||||||
|
work_order_no=pending_wo,
|
||||||
|
allow_terminal=True,
|
||||||
|
)
|
||||||
|
if ticket is None:
|
||||||
|
return phase
|
||||||
|
return apply_air_release(
|
||||||
|
flow=engine,
|
||||||
|
ticket=ticket,
|
||||||
|
sender_id=message.sender_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
instruction=text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_cabin_cancel(
|
||||||
|
engine,
|
||||||
|
message: InboundMessage,
|
||||||
|
*,
|
||||||
|
work_order_no: str,
|
||||||
|
) -> str:
|
||||||
|
"""航线说了释放舱位:只提问,不打 TMS。"""
|
||||||
|
from agent.policy.air_group_ops import is_airline
|
||||||
|
from agent.redis_coord.pending_cabin_cancel import save_pending_cabin_cancel
|
||||||
|
|
||||||
|
chat_id = message.chat_id
|
||||||
|
if not is_airline(engine, message.sender_id):
|
||||||
|
send_group_text(group_client_of(engine), chat_id, copy.air_airline_only_cabin())
|
||||||
|
return "not_airline"
|
||||||
|
no = (work_order_no or "").strip()
|
||||||
|
if not no:
|
||||||
|
bound = current_ticket(engine, chat_id)
|
||||||
|
if bound is not None and is_air_ticket(bound):
|
||||||
|
from agent.policy.sea_group_ops import ticket_no
|
||||||
|
|
||||||
|
no = ticket_no(bound)
|
||||||
|
if not 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(
|
||||||
|
flow=engine,
|
||||||
|
chat_id=chat_id,
|
||||||
|
work_order_no=no,
|
||||||
|
allow_terminal=True,
|
||||||
|
)
|
||||||
|
if ticket is None:
|
||||||
|
return phase
|
||||||
|
from agent.policy.sea_group_ops import ticket_no
|
||||||
|
|
||||||
|
save_pending_cabin_cancel(chat_id, ticket_no(ticket))
|
||||||
|
send_group_text(group_client_of(engine), chat_id, copy.air_release_ask())
|
||||||
|
return "release_ask"
|
||||||
|
|
||||||
|
|
||||||
def _handle_cabin(
|
def _handle_cabin(
|
||||||
engine,
|
engine,
|
||||||
message: InboundMessage,
|
message: InboundMessage,
|
||||||
@@ -319,6 +410,12 @@ def _handle_cabin(
|
|||||||
) -> str:
|
) -> str:
|
||||||
_ = injected_quote
|
_ = injected_quote
|
||||||
chat_id = message.chat_id
|
chat_id = message.chat_id
|
||||||
|
if intent == INTENT_RELEASE_ASK:
|
||||||
|
return _ask_cabin_cancel(
|
||||||
|
engine,
|
||||||
|
message,
|
||||||
|
work_order_no=work_order_no,
|
||||||
|
)
|
||||||
if not work_order_no:
|
if not work_order_no:
|
||||||
# 本群已经在锁这张单时,补舱位不用再报工单号。
|
# 本群已经在锁这张单时,补舱位不用再报工单号。
|
||||||
bound = current_ticket(engine, chat_id)
|
bound = current_ticket(engine, chat_id)
|
||||||
|
|||||||
@@ -371,6 +371,68 @@ def _tighten_sheet_labels(text: str) -> str:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
# 「货交上海」是提货地址,不是起运地。地址/地、到/于只是口语粘连,不进地址正文。
|
||||||
|
_RE_CARGO_HANDOVER = re.compile(
|
||||||
|
r"货交\s*(?:地址|地)?\s*[::]?\s*(?:到|于|至|往)?\s*([^\n,,。;;]{1,60})"
|
||||||
|
)
|
||||||
|
# 同一行跟在地址后面的询价口头禅或下一栏字段,不是地址的一部分。
|
||||||
|
_RE_HANDOVER_TAIL = re.compile(
|
||||||
|
r"(?:\s+|,|,)?(?:看看价格|看一下价格|看价格|询价|报价|多少钱|多少|"
|
||||||
|
r"目的地|目的港|起运地|起运港|品名|件数|毛重|体积|包装|空运|海运|陆运).*$"
|
||||||
|
)
|
||||||
|
# 另有起运说法时保留起运地。先抠掉「货交…」,避免「货交到上海」被当成「到上海」。
|
||||||
|
_RE_EXPLICIT_ORIGIN = re.compile(
|
||||||
|
r"起运地|起运港|"
|
||||||
|
r"[A-Za-z]{3}\s*[-–—//]\s*[A-Za-z]{3}|"
|
||||||
|
r"[A-Za-z]{3}\s*(?:到|至)\s*[A-Za-z]{3}|"
|
||||||
|
r"从\s*\S{1,16}\s*(?:飞|到|至)|"
|
||||||
|
r"[\u4e00-\u9fff]{2,12}\s*飞|"
|
||||||
|
r"[\u4e00-\u9fff]{2,12}\s*到\s*[\u4e00-\u9fffA-Za-z]{2,12}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _handover_place(raw: str) -> str:
|
||||||
|
"""货交后面的地名。去掉口头禅和首尾标点,空的不当地址。"""
|
||||||
|
place = _RE_HANDOVER_TAIL.sub("", (raw or "").strip())
|
||||||
|
return place.strip(" ::,,。;;")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_explicit_origin(text: str) -> bool:
|
||||||
|
"""原话里另写了起运地、三字码航线或「从哪飞/到」。货交那一段先拿掉。"""
|
||||||
|
stripped = _RE_CARGO_HANDOVER.sub(" ", text or "")
|
||||||
|
return _RE_EXPLICIT_ORIGIN.search(stripped) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_cargo_handover_synonym(
|
||||||
|
text: str,
|
||||||
|
facts: dict[str, str] | None,
|
||||||
|
*,
|
||||||
|
business_line: str = "",
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
货交 = 提货地址。例:货交上海 → 提货地址上海。
|
||||||
|
|
||||||
|
只认原话里的「货交」,不把裸城市猜成港口。
|
||||||
|
空运且没有另写起运地时,清掉被误收成起运地的同一处地名,否则 TMS 会按错误起运查价。
|
||||||
|
海运/陆运只补提货地址,不动起运港。副作用:无。
|
||||||
|
"""
|
||||||
|
merged = dict(facts or {})
|
||||||
|
places: list[str] = []
|
||||||
|
for hit in _RE_CARGO_HANDOVER.finditer(text or ""):
|
||||||
|
place = _handover_place(hit.group(1))
|
||||||
|
if place and place not in places:
|
||||||
|
places.append(place)
|
||||||
|
if not places:
|
||||||
|
return merged
|
||||||
|
merged["提货地址"] = "、".join(places)
|
||||||
|
is_air = (business_line or "").strip().upper() == "AIR" or "空运" in (text or "")
|
||||||
|
if is_air and not _has_explicit_origin(text):
|
||||||
|
merged.pop("起运港", None)
|
||||||
|
merged.pop("起运地", None)
|
||||||
|
merged.pop("originCode", None)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _drop_fee_place_values(facts: dict[str, str]) -> dict[str, str]:
|
def _drop_fee_place_values(facts: dict[str, str]) -> dict[str, str]:
|
||||||
"""起运地/目的地收成「操作费、文件费」时丢掉。那是费用栏,不是港口。"""
|
"""起运地/目的地收成「操作费、文件费」时丢掉。那是费用栏,不是港口。"""
|
||||||
out = dict(facts or {})
|
out = dict(facts or {})
|
||||||
@@ -407,6 +469,7 @@ def extract_inquiry_snapshot(
|
|||||||
facts = harvest_oral_measures(text, normalize_facts(dict(injected_facts)))
|
facts = harvest_oral_measures(text, normalize_facts(dict(injected_facts)))
|
||||||
facts = _merge_spoken_collab(text, facts)
|
facts = _merge_spoken_collab(text, facts)
|
||||||
facts = _merge_optional_inquiry_labels(text, facts)
|
facts = _merge_optional_inquiry_labels(text, facts)
|
||||||
|
facts = apply_cargo_handover_synonym(text, facts, business_line=mode)
|
||||||
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
|
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
|
||||||
if str(facts.get(key) or "").strip():
|
if str(facts.get(key) or "").strip():
|
||||||
facts[key] = clip_inquiry_value(str(facts[key]))
|
facts[key] = clip_inquiry_value(str(facts[key]))
|
||||||
@@ -453,6 +516,7 @@ def extract_inquiry_snapshot(
|
|||||||
facts = harvest_oral_measures(text, normalize_facts(facts))
|
facts = harvest_oral_measures(text, normalize_facts(facts))
|
||||||
facts = _merge_spoken_collab(text, facts)
|
facts = _merge_spoken_collab(text, facts)
|
||||||
facts = _merge_optional_inquiry_labels(text, facts)
|
facts = _merge_optional_inquiry_labels(text, facts)
|
||||||
|
facts = apply_cargo_handover_synonym(text, facts, business_line=mode)
|
||||||
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
|
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
|
||||||
if str(facts.get(key) or "").strip():
|
if str(facts.get(key) or "").strip():
|
||||||
facts[key] = clip_inquiry_value(str(facts[key]))
|
facts[key] = clip_inquiry_value(str(facts[key]))
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ _FIELD_KEYS = (
|
|||||||
"车型数量",
|
"车型数量",
|
||||||
"通关口岸",
|
"通关口岸",
|
||||||
"贸易条款",
|
"贸易条款",
|
||||||
|
"提货地址",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -134,6 +135,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
|
|||||||
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
|
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
|
||||||
"原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。"
|
"原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。"
|
||||||
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
|
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
|
||||||
|
"「货交上海」「货交:上海」「货交到上海」是提货地址=上海,不是起运港。"
|
||||||
|
"没有另写起运地、三字码航线或「从哪飞/到」时,起运港必须空串。"
|
||||||
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
|
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
|
||||||
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
|
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
|
||||||
"换城市必须覆盖旧值,不要沿用上一句的港口。"
|
"换城市必须覆盖旧值,不要沿用上一句的港口。"
|
||||||
|
|||||||
@@ -1068,7 +1068,7 @@ def apply_air_release(
|
|||||||
send_group_text(
|
send_group_text(
|
||||||
group_client_of(flow),
|
group_client_of(flow),
|
||||||
chat_id,
|
chat_id,
|
||||||
copy.air_release_fail(work_order_no=no, operator=name, reason="主账未开放释放"),
|
copy.air_release_fail(work_order_no=no, operator=name, reason="主账未开放取消舱位"),
|
||||||
)
|
)
|
||||||
return "release_unavailable"
|
return "release_unavailable"
|
||||||
from agent.policy.system_exception import (
|
from agent.policy.system_exception import (
|
||||||
@@ -1104,7 +1104,7 @@ def apply_air_release(
|
|||||||
event=SystemExceptionEvent(
|
event=SystemExceptionEvent(
|
||||||
exception_type=TYPE_TMS,
|
exception_type=TYPE_TMS,
|
||||||
step=STEP_TMS_RELEASE,
|
step=STEP_TMS_RELEASE,
|
||||||
reason="空运释放舱位接口调用失败",
|
reason="空运取消舱位接口调用失败",
|
||||||
service="空运舱位",
|
service="空运舱位",
|
||||||
error_code=why or "RELEASE_FAIL",
|
error_code=why or "RELEASE_FAIL",
|
||||||
work_order_no=no,
|
work_order_no=no,
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ def _r(code: str, default: str, **kwargs: Any) -> str:
|
|||||||
return render(code, default, **kwargs)
|
return render(code, default, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _cabin_copy(code: str, default: str, **kwargs: Any) -> str:
|
||||||
|
"""
|
||||||
|
舱位话术。库里旧句仍写「释放」时,新发出的改用当前默认句。
|
||||||
|
|
||||||
|
先把填进去的原因、工单号抠掉再看骨架,避免 TMS 原文里的「释放」把新句子打回去。
|
||||||
|
已经发出的历史消息不在这里改。
|
||||||
|
"""
|
||||||
|
text = _r(code, default, **kwargs)
|
||||||
|
skeleton = text
|
||||||
|
for value in kwargs.values():
|
||||||
|
token = str(value or "")
|
||||||
|
if token:
|
||||||
|
skeleton = skeleton.replace(token, "")
|
||||||
|
if "释放" in skeleton:
|
||||||
|
return default
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
|
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
|
||||||
_AIR_EXAMPLES = {
|
_AIR_EXAMPLES = {
|
||||||
"起运港": "南京",
|
"起运港": "南京",
|
||||||
@@ -314,7 +332,7 @@ _LAND_EXAMPLES = {
|
|||||||
"体积": "100CBM",
|
"体积": "100CBM",
|
||||||
"毛重": "50kg",
|
"毛重": "50kg",
|
||||||
"车型/数量": "9米6/1",
|
"车型/数量": "9米6/1",
|
||||||
"通关口岸": "皇岗",
|
"通关口岸": "友谊关",
|
||||||
"运输类型": "国内运输拼车",
|
"运输类型": "国内运输拼车",
|
||||||
"线路类别": "国内长途/零担",
|
"线路类别": "国内长途/零担",
|
||||||
"运输分类": "急件",
|
"运输分类": "急件",
|
||||||
@@ -2879,9 +2897,9 @@ def air_need_work_order() -> str:
|
|||||||
|
|
||||||
def air_lock_need_work_order() -> str:
|
def air_lock_need_work_order() -> str:
|
||||||
"""锁舱/释放必须自带工单号。后台 inquiring.air_lock_need_wo。"""
|
"""锁舱/释放必须自带工单号。后台 inquiring.air_lock_need_wo。"""
|
||||||
return _r(
|
return _cabin_copy(
|
||||||
"inquiring.air_lock_need_wo",
|
"inquiring.air_lock_need_wo",
|
||||||
"锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。",
|
"锁舱或取消舱位请带上工单号,例如 WO202609160001取消舱位。",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3041,9 +3059,9 @@ def air_airline_only_quote() -> str:
|
|||||||
|
|
||||||
def air_airline_only_cabin() -> str:
|
def air_airline_only_cabin() -> str:
|
||||||
"""只有航线能锁舱或释放。后台 inquiring.air_airline_only_cabin。"""
|
"""只有航线能锁舱或释放。后台 inquiring.air_airline_only_cabin。"""
|
||||||
return _r(
|
return _cabin_copy(
|
||||||
"inquiring.air_airline_only_cabin",
|
"inquiring.air_airline_only_cabin",
|
||||||
"只有岗位为「航线·空运」的人能锁舱或释放舱位。",
|
"只有岗位为「航线·空运」的人能锁舱或取消舱位。",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3264,13 +3282,13 @@ def air_release_ok(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""空运释放成功。后台 inquiring.air_release_ok。"""
|
"""空运释放成功。后台 inquiring.air_release_ok。"""
|
||||||
default = (
|
default = (
|
||||||
f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n"
|
f"工单{work_order_no} 已通知TMS执行空运取消舱位成功\n"
|
||||||
f"操作人:{operator}\n"
|
f"操作人:{operator}\n"
|
||||||
f"舱位选项:{option_no}\n"
|
f"舱位选项:{option_no}\n"
|
||||||
f"执行编号:{exec_no}\n"
|
f"执行编号:{exec_no}\n"
|
||||||
f"报价编号:{quote_no}"
|
f"报价编号:{quote_no}"
|
||||||
)
|
)
|
||||||
return _r(
|
return _cabin_copy(
|
||||||
"inquiring.air_release_ok",
|
"inquiring.air_release_ok",
|
||||||
default,
|
default,
|
||||||
ticket_no=work_order_no,
|
ticket_no=work_order_no,
|
||||||
@@ -3285,12 +3303,12 @@ def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
|||||||
"""空运释放失败。后台 inquiring.air_release_fail。"""
|
"""空运释放失败。后台 inquiring.air_release_fail。"""
|
||||||
why = (reason or "").strip() or "TMS未返回原因"
|
why = (reason or "").strip() or "TMS未返回原因"
|
||||||
default = (
|
default = (
|
||||||
f"工单{work_order_no} 空运释放舱位失败。\n"
|
f"工单{work_order_no} 空运取消舱位失败。\n"
|
||||||
f"操作人:{operator}\n"
|
f"操作人:{operator}\n"
|
||||||
f"释放舱位失败的原因:{why}\n"
|
f"取消舱位失败的原因:{why}\n"
|
||||||
"已通知IT运维排查,请稍等。"
|
"已通知IT运维排查,请稍等。"
|
||||||
)
|
)
|
||||||
return _r(
|
return _cabin_copy(
|
||||||
"inquiring.air_release_fail",
|
"inquiring.air_release_fail",
|
||||||
default,
|
default,
|
||||||
ticket_no=work_order_no,
|
ticket_no=work_order_no,
|
||||||
@@ -3302,9 +3320,9 @@ def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
|||||||
def air_release_deal_blocked(work_order_no: str) -> str:
|
def air_release_deal_blocked(work_order_no: str) -> str:
|
||||||
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
|
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
|
||||||
no = (work_order_no or "").strip()
|
no = (work_order_no or "").strip()
|
||||||
return _r(
|
return _cabin_copy(
|
||||||
"inquiring.air_release_deal_blocked",
|
"inquiring.air_release_deal_blocked",
|
||||||
f"释放舱位失败,工单{no}已成交。",
|
f"取消舱位失败,工单{no}已成交。",
|
||||||
ticket_no=no,
|
ticket_no=no,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3312,9 +3330,9 @@ def air_release_deal_blocked(work_order_no: str) -> str:
|
|||||||
def air_release_need_lock(work_order_no: str) -> str:
|
def air_release_need_lock(work_order_no: str) -> str:
|
||||||
"""没有有效锁舱不能释放。后台 inquiring.air_release_need_lock。"""
|
"""没有有效锁舱不能释放。后台 inquiring.air_release_need_lock。"""
|
||||||
no = (work_order_no or "").strip()
|
no = (work_order_no or "").strip()
|
||||||
return _r(
|
return _cabin_copy(
|
||||||
"inquiring.air_release_need_lock",
|
"inquiring.air_release_need_lock",
|
||||||
f"工单{no}还没有有效锁舱,不能释放。",
|
f"工单{no}还没有有效锁舱,不能取消舱位。",
|
||||||
ticket_no=no,
|
ticket_no=no,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3342,9 +3360,19 @@ SYS_EXC_RECOVERY_GROUP = "建群已恢复正常,感谢您的耐心等待。"
|
|||||||
IMPACT_LLM_NO_TICKET = "尚未建单,不落后台工单"
|
IMPACT_LLM_NO_TICKET = "尚未建单,不落后台工单"
|
||||||
IMPACT_LLM = "当前识别暂停,已有字段与工单状态保持不变"
|
IMPACT_LLM = "当前识别暂停,已有字段与工单状态保持不变"
|
||||||
IMPACT_TMS_QUOTE = "当前询价流程暂停,识别字段与原业务节点保持不变"
|
IMPACT_TMS_QUOTE = "当前询价流程暂停,识别字段与原业务节点保持不变"
|
||||||
IMPACT_TMS_CABIN = "当前锁舱/释放暂停,工单状态保持不变"
|
IMPACT_TMS_CABIN = "当前锁舱/取消舱位暂停,工单状态保持不变"
|
||||||
IMPACT_TMS_LOCK = "锁舱失败,工单状态保持不变"
|
IMPACT_TMS_LOCK = "锁舱失败,工单状态保持不变"
|
||||||
IMPACT_TMS_RELEASE = "释放舱位失败,工单状态保持不变"
|
IMPACT_TMS_RELEASE = "取消舱位失败,工单状态保持不变"
|
||||||
|
|
||||||
|
|
||||||
|
def air_release_ask() -> str:
|
||||||
|
"""航线说了和舱位有关的「释放」时先问,不直接取消。"""
|
||||||
|
return "是否需要取消舱位?"
|
||||||
|
|
||||||
|
|
||||||
|
def air_release_ask_dropped() -> str:
|
||||||
|
"""航线否认这次取消。不打 TMS。"""
|
||||||
|
return "已取消这次操作"
|
||||||
IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群"
|
IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,15 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i
|
|||||||
operator_name,
|
operator_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if intent == "ask":
|
||||||
|
if not is_airline(flow, sender_id):
|
||||||
|
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||||
|
return "not_airline"
|
||||||
|
from agent.redis_coord.pending_cabin_cancel import save_pending_cabin_cancel
|
||||||
|
|
||||||
|
save_pending_cabin_cancel(chat_id, ticket_no(ticket))
|
||||||
|
send_group_text(group_client_of(flow), chat_id, copy.air_release_ask())
|
||||||
|
return "release_ask"
|
||||||
if intent == "release":
|
if intent == "release":
|
||||||
return apply_air_release(
|
return apply_air_release(
|
||||||
flow=flow,
|
flow=flow,
|
||||||
@@ -314,10 +323,15 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i
|
|||||||
|
|
||||||
|
|
||||||
def _cabin_intent(text: str) -> str:
|
def _cabin_intent(text: str) -> str:
|
||||||
raw = text or ""
|
"""取消舱位马上办;和舱位有关的「释放」只提问。"""
|
||||||
if "释放" in raw and "舱" in raw:
|
from agent.routing.air_group_intent import cabin_release_kind
|
||||||
|
|
||||||
|
kind = cabin_release_kind(text or "")
|
||||||
|
if kind == "direct":
|
||||||
return "release"
|
return "release"
|
||||||
if "锁舱" in raw:
|
if kind == "ask":
|
||||||
|
return "ask"
|
||||||
|
if "锁舱" in (text or ""):
|
||||||
return "lock"
|
return "lock"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -888,7 +888,7 @@ def _resume_cabin_step(
|
|||||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||||
)
|
)
|
||||||
service = "空运舱位"
|
service = "空运舱位"
|
||||||
reason = "空运释放舱位接口调用失败"
|
reason = "空运取消舱位接口调用失败"
|
||||||
else:
|
else:
|
||||||
# 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。
|
# 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。
|
||||||
locker = getattr(ledger, "lock_cabin", None)
|
locker = getattr(ledger, "lock_cabin", None)
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ QUOTE_FILE_READY = "quote_file_ready"
|
|||||||
PENDING_PRODUCT_QUOTE = "pending_product_quote"
|
PENDING_PRODUCT_QUOTE = "pending_product_quote"
|
||||||
# 陆运核对卡已出、等确定查价:短协调,不当六态、不当工单号
|
# 陆运核对卡已出、等确定查价:短协调,不当六态、不当工单号
|
||||||
PENDING_LAND_CONFIRM = "pending_land_confirm"
|
PENDING_LAND_CONFIRM = "pending_land_confirm"
|
||||||
|
# 空运群问过「是否需要取消舱位」:短协调,不当六态
|
||||||
|
PENDING_CABIN_CANCEL = "pending_cabin_cancel"
|
||||||
# 空运/海运/陆运当前书签:HTTP 与 Worker 共用,不当六态
|
# 空运/海运/陆运当前书签:HTTP 与 Worker 共用,不当六态
|
||||||
FLOW_BOOKMARK = "flow_bookmark"
|
FLOW_BOOKMARK = "flow_bookmark"
|
||||||
# 私聊图片波次:HTTP 与 Worker 必须共用,不当六态
|
# 私聊图片波次:HTTP 与 Worker 必须共用,不当六态
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
空运群取消舱位待确认。
|
||||||
|
|
||||||
|
航线说出和舱位有关的「释放」后先问一句,确认前不打 TMS。
|
||||||
|
短协调,不当六态、不当 checkpoint。按群记下工单号,双进程仍能读到。
|
||||||
|
两小时没人接话就过期,避免过后回一个「是」误取消。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from agent.redis_coord.keys import PENDING_CABIN_CANCEL
|
||||||
|
from agent.redis_coord.runtime import get_redis_runtime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 问句只在这一阵有效。过期后「是」不再取消舱位。
|
||||||
|
_TTL_SECONDS = 2 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def save_pending_cabin_cancel(chat_id: str, work_order_no: str) -> None:
|
||||||
|
"""记下本群正在等航线确认取消哪张单。"""
|
||||||
|
room = (chat_id or "").strip()
|
||||||
|
no = (work_order_no or "").strip()
|
||||||
|
if not room or not no:
|
||||||
|
return
|
||||||
|
payload = {"work_order_no": no}
|
||||||
|
try:
|
||||||
|
client = get_redis_runtime().client
|
||||||
|
client.raw.set(
|
||||||
|
client.key(PENDING_CABIN_CANCEL, room),
|
||||||
|
json.dumps(payload, ensure_ascii=False),
|
||||||
|
ex=_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
logger.info("cabin.ask saved chat=%s wo=%s", room, no)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("写取消舱位待确认失败 chat=%s", room)
|
||||||
|
|
||||||
|
|
||||||
|
def load_pending_cabin_cancel(chat_id: str) -> str:
|
||||||
|
"""本群正在等确认的工单号。没有则空串。"""
|
||||||
|
room = (chat_id or "").strip()
|
||||||
|
if not room:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
client = get_redis_runtime().client
|
||||||
|
raw = client.raw.get(client.key(PENDING_CABIN_CANCEL, room))
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
raw = raw.decode("utf-8")
|
||||||
|
data = json.loads(raw)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return ""
|
||||||
|
return str(data.get("work_order_no") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("读取消舱位待确认失败 chat=%s", room)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def clear_pending_cabin_cancel(chat_id: str) -> None:
|
||||||
|
"""确认、否认或改口办别的事时清掉。"""
|
||||||
|
room = (chat_id or "").strip()
|
||||||
|
if not room:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client = get_redis_runtime().client
|
||||||
|
client.raw.delete(client.key(PENDING_CABIN_CANCEL, room))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("清取消舱位待确认失败 chat=%s", room)
|
||||||
@@ -35,12 +35,15 @@ logger = logging.getLogger(__name__)
|
|||||||
INTENT_ACTIVATE = "air_activate"
|
INTENT_ACTIVATE = "air_activate"
|
||||||
INTENT_LOCK = "air_lock"
|
INTENT_LOCK = "air_lock"
|
||||||
INTENT_RELEASE = "air_release"
|
INTENT_RELEASE = "air_release"
|
||||||
|
# 说出和舱位有关的「释放」:先问是否取消,不直接执行。
|
||||||
|
INTENT_RELEASE_ASK = "air_release_ask"
|
||||||
INTENT_PICK_OPTION = "air_pick_option"
|
INTENT_PICK_OPTION = "air_pick_option"
|
||||||
|
|
||||||
_VALID = {
|
_VALID = {
|
||||||
INTENT_ACTIVATE,
|
INTENT_ACTIVATE,
|
||||||
INTENT_LOCK,
|
INTENT_LOCK,
|
||||||
INTENT_RELEASE,
|
INTENT_RELEASE,
|
||||||
|
INTENT_RELEASE_ASK,
|
||||||
INTENT_PICK_OPTION,
|
INTENT_PICK_OPTION,
|
||||||
INTENT_FIELDS,
|
INTENT_FIELDS,
|
||||||
INTENT_QUOTE,
|
INTENT_QUOTE,
|
||||||
@@ -56,7 +59,10 @@ _VALID = {
|
|||||||
|
|
||||||
# 相近说法回退,不是唯一口令。
|
# 相近说法回退,不是唯一口令。
|
||||||
_LOCK_HINTS = ("锁舱", "锁仓", "锁定舱位", "锁住舱位", "帮我锁舱")
|
_LOCK_HINTS = ("锁舱", "锁仓", "锁定舱位", "锁住舱位", "帮我锁舱")
|
||||||
_RELEASE_HINTS = ("释放舱位", "放舱", "解锁舱", "取消锁舱", "释放舱")
|
# 这四句马上取消舱位。「释放舱位」不在这里。
|
||||||
|
_RELEASE_DIRECT = ("取消舱位", "放舱", "解锁舱", "取消锁舱")
|
||||||
|
_CONFIRM_YES = ("是", "需要", "确认")
|
||||||
|
_CONFIRM_NO = ("不需要", "不用", "否")
|
||||||
|
|
||||||
|
|
||||||
def is_missed_air_bot_at(text: str, *, room_already_air: bool = False) -> bool:
|
def is_missed_air_bot_at(text: str, *, room_already_air: bool = False) -> bool:
|
||||||
@@ -154,10 +160,53 @@ def classify_air_group_text(
|
|||||||
return INTENT_OTHER
|
return INTENT_OTHER
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_cabin_text(text: str) -> str:
|
||||||
|
"""去掉空白,方便认「取消舱位」「释放舱位」。"""
|
||||||
|
return (text or "").replace(" ", "").replace(" ", "")
|
||||||
|
|
||||||
|
|
||||||
|
def cabin_release_kind(text: str) -> str:
|
||||||
|
"""
|
||||||
|
舱位取消怎么认。
|
||||||
|
|
||||||
|
direct:马上执行。ask:先问「是否需要取消舱位」。
|
||||||
|
跟舱位有关的「释放」只问不办。没说舱位的「释放」不当取消。
|
||||||
|
"""
|
||||||
|
blob = _compact_cabin_text(text)
|
||||||
|
# 「释放舱位」里含着「放舱」,必须先认释放,避免被放舱直接取消。
|
||||||
|
if "释放" in blob and "舱" in blob:
|
||||||
|
return "ask"
|
||||||
|
if any(hint in blob for hint in _RELEASE_DIRECT):
|
||||||
|
return "direct"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def cabin_confirm_answer(text: str) -> str:
|
||||||
|
"""
|
||||||
|
问过之后这一句是确认、否认,还是别的业务。
|
||||||
|
|
||||||
|
只认整句(可带工单号和 @)。「不需要」不能被看成「需要」。
|
||||||
|
返回 yes / no / 空串。空串表示询问作废,调用方按原句继续办。
|
||||||
|
"""
|
||||||
|
raw = text or ""
|
||||||
|
no = copy.extract_work_order_no(raw)
|
||||||
|
rest = raw.replace(no, "").replace(no.lower(), "")
|
||||||
|
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
|
||||||
|
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
|
||||||
|
if rest in _CONFIRM_NO:
|
||||||
|
return "no"
|
||||||
|
if rest in _CONFIRM_YES or rest == "取消舱位":
|
||||||
|
return "yes"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _hint_lock_or_release(text: str) -> str:
|
def _hint_lock_or_release(text: str) -> str:
|
||||||
blob = (text or "").replace(" ", "").replace(" ", "")
|
kind = cabin_release_kind(text)
|
||||||
if any(x in blob for x in _RELEASE_HINTS):
|
if kind == "direct":
|
||||||
return INTENT_RELEASE
|
return INTENT_RELEASE
|
||||||
|
if kind == "ask":
|
||||||
|
return INTENT_RELEASE_ASK
|
||||||
|
blob = _compact_cabin_text(text)
|
||||||
if any(x in blob for x in _LOCK_HINTS):
|
if any(x in blob for x in _LOCK_HINTS):
|
||||||
return INTENT_LOCK
|
return INTENT_LOCK
|
||||||
return ""
|
return ""
|
||||||
@@ -191,7 +240,8 @@ def _classify_with_model(text: str, settings) -> str:
|
|||||||
client = LlmHttpClient.from_settings(settings)
|
client = LlmHttpClient.from_settings(settings)
|
||||||
system = (
|
system = (
|
||||||
"你是空运协同群意图分类。只回复一个英文词,不要解释。"
|
"你是空运协同群意图分类。只回复一个英文词,不要解释。"
|
||||||
"air_lock=航线要锁舱;air_release=航线要释放舱位;"
|
"air_lock=航线要锁舱;air_release=航线要取消舱位(取消舱位、放舱、解锁舱、取消锁舱);"
|
||||||
|
"说出释放舱位、释放舱、把舱位释放了时不要回 air_release;"
|
||||||
"air_activate=只是指定/切换工单号;"
|
"air_activate=只是指定/切换工单号;"
|
||||||
"air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;"
|
"air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;"
|
||||||
"collab_fields=在补是否含电/是否含磁;"
|
"collab_fields=在补是否含电/是否含磁;"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from agent.policy import inquiry_copy as copy
|
|||||||
from agent.routing.air_group_intent import (
|
from agent.routing.air_group_intent import (
|
||||||
INTENT_LOCK,
|
INTENT_LOCK,
|
||||||
INTENT_RELEASE,
|
INTENT_RELEASE,
|
||||||
|
INTENT_RELEASE_ASK,
|
||||||
classify_air_group_text,
|
classify_air_group_text,
|
||||||
)
|
)
|
||||||
from agent.routing.dispatch import dispatch_inbound
|
from agent.routing.dispatch import dispatch_inbound
|
||||||
@@ -201,7 +202,12 @@ class AirGroupCollabTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_intent_lock_is_not_hardcoded(self) -> None:
|
def test_intent_lock_is_not_hardcoded(self) -> None:
|
||||||
self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK)
|
self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK)
|
||||||
self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE)
|
self.assertEqual(classify_air_group_text("WO202609160001取消舱位", allow_network=False), INTENT_RELEASE)
|
||||||
|
self.assertEqual(classify_air_group_text("放舱", allow_network=False), INTENT_RELEASE)
|
||||||
|
self.assertEqual(classify_air_group_text("解锁舱", allow_network=False), INTENT_RELEASE)
|
||||||
|
self.assertEqual(classify_air_group_text("取消锁舱", allow_network=False), INTENT_RELEASE)
|
||||||
|
self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE_ASK)
|
||||||
|
self.assertEqual(classify_air_group_text("把舱位释放了", allow_network=False), INTENT_RELEASE_ASK)
|
||||||
|
|
||||||
def test_airline_fee_wording_is_quote_not_adjust(self) -> None:
|
def test_airline_fee_wording_is_quote_not_adjust(self) -> None:
|
||||||
"""航线写「空运费改为900」是手工报价,不能先判成调价。比例和改总价仍是调价。"""
|
"""航线写「空运费改为900」是手工报价,不能先判成调价。比例和改总价仍是调价。"""
|
||||||
@@ -1278,9 +1284,84 @@ class AirGroupCollabTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(phase, "release_ok")
|
self.assertEqual(phase, "release_ok")
|
||||||
added = "\n".join(self._texts()[before:])
|
added = "\n".join(self._texts()[before:])
|
||||||
self.assertIn("释放", added)
|
self.assertIn("取消舱位", added)
|
||||||
self.assertNotIn("请确认成交状态", added)
|
self.assertNotIn("请确认成交状态", added)
|
||||||
|
|
||||||
|
def test_release_wording_asks_before_cancel(self) -> None:
|
||||||
|
"""说出释放舱位只提问。销售回「是」不算,航线确认后才取消。"""
|
||||||
|
wo = self._create_air()
|
||||||
|
handle_air_group(
|
||||||
|
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask", mid="ask0"),
|
||||||
|
flow=self.flow,
|
||||||
|
ledger=self.ledger,
|
||||||
|
injected_intent="air_lock",
|
||||||
|
)
|
||||||
|
self.groups.sent.clear()
|
||||||
|
phase = handle_air_group(
|
||||||
|
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_ask", mid="ask1"),
|
||||||
|
flow=self.flow,
|
||||||
|
ledger=self.ledger,
|
||||||
|
)
|
||||||
|
self.assertEqual(phase, "release_ask")
|
||||||
|
self.assertIn("是否需要取消舱位?", "\n".join(self._texts()))
|
||||||
|
self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
|
||||||
|
phase = handle_air_group(
|
||||||
|
self._msg(sender="sales1", text="是", chat="wr_air_ask", mid="ask2"),
|
||||||
|
flow=self.flow,
|
||||||
|
ledger=self.ledger,
|
||||||
|
)
|
||||||
|
self.assertEqual(phase, "not_airline")
|
||||||
|
self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
|
||||||
|
phase = handle_air_group(
|
||||||
|
self._msg(sender="air1", text="是", chat="wr_air_ask", mid="ask3"),
|
||||||
|
flow=self.flow,
|
||||||
|
ledger=self.ledger,
|
||||||
|
)
|
||||||
|
self.assertEqual(phase, "release_ok")
|
||||||
|
self.assertIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
|
||||||
|
|
||||||
|
def test_release_wording_can_be_declined(self) -> None:
|
||||||
|
wo = self._create_air()
|
||||||
|
handle_air_group(
|
||||||
|
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask_no", mid="no0"),
|
||||||
|
flow=self.flow,
|
||||||
|
ledger=self.ledger,
|
||||||
|
injected_intent="air_lock",
|
||||||
|
)
|
||||||
|
self.groups.sent.clear()
|
||||||
|
handle_air_group(
|
||||||
|
self._msg(sender="air1", text=f"{wo}把舱位释放了", chat="wr_air_ask_no", mid="no1"),
|
||||||
|
flow=self.flow,
|
||||||
|
ledger=self.ledger,
|
||||||
|
)
|
||||||
|
phase = handle_air_group(
|
||||||
|
self._msg(sender="air1", text="不用", chat="wr_air_ask_no", mid="no2"),
|
||||||
|
flow=self.flow,
|
||||||
|
ledger=self.ledger,
|
||||||
|
)
|
||||||
|
self.assertEqual(phase, "release_ask_dropped")
|
||||||
|
self.assertIn("已取消这次操作", "\n".join(self._texts()))
|
||||||
|
self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
|
||||||
|
|
||||||
|
def test_cancel_cabin_words_release_immediately(self) -> None:
|
||||||
|
wo = self._create_air()
|
||||||
|
handle_air_group(
|
||||||
|
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_now", mid="now0"),
|
||||||
|
flow=self.flow,
|
||||||
|
ledger=self.ledger,
|
||||||
|
injected_intent="air_lock",
|
||||||
|
)
|
||||||
|
self.groups.sent.clear()
|
||||||
|
phase = handle_air_group(
|
||||||
|
self._msg(sender="air1", text=f"{wo}取消舱位", chat="wr_air_now", mid="now1"),
|
||||||
|
flow=self.flow,
|
||||||
|
ledger=self.ledger,
|
||||||
|
)
|
||||||
|
self.assertEqual(phase, "release_ok")
|
||||||
|
joined = "\n".join(self._texts())
|
||||||
|
self.assertIn("已通知TMS执行空运取消舱位成功", joined)
|
||||||
|
self.assertNotIn("是否需要取消舱位?", joined)
|
||||||
|
|
||||||
def test_sales_cannot_lock(self) -> None:
|
def test_sales_cannot_lock(self) -> None:
|
||||||
wo = self._create_air()
|
wo = self._create_air()
|
||||||
phase = handle_air_group(
|
phase = handle_air_group(
|
||||||
@@ -1825,9 +1906,9 @@ class AirGroupCollabTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(phase, "system_exception")
|
self.assertEqual(phase, "system_exception")
|
||||||
joined = "\n".join(self._texts())
|
joined = "\n".join(self._texts())
|
||||||
self.assertIn(f"工单{wo} 空运释放舱位失败。", joined)
|
self.assertIn(f"工单{wo} 空运取消舱位失败。", joined)
|
||||||
self.assertIn("操作人:HangXian", joined)
|
self.assertIn("操作人:HangXian", joined)
|
||||||
self.assertIn("释放舱位失败的原因:舱位不存在", joined)
|
self.assertIn("取消舱位失败的原因:舱位不存在", joined)
|
||||||
self.assertIn("已通知IT运维排查,请稍等。", joined)
|
self.assertIn("已通知IT运维排查,请稍等。", joined)
|
||||||
self.assertNotIn("TMS接口异常,IT运维排查中", joined)
|
self.assertNotIn("TMS接口异常,IT运维排查中", joined)
|
||||||
|
|
||||||
|
|||||||
@@ -624,6 +624,79 @@ AIR_QUOTE_SHEET = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CargoHandoverSynonymTests(unittest.TestCase):
|
||||||
|
"""货交是提货地址,不能当成起运地。"""
|
||||||
|
|
||||||
|
def test_huojiao_shanghai_is_pickup_not_origin(self) -> None:
|
||||||
|
text = (
|
||||||
|
"KUL 空运 普货 散货\n"
|
||||||
|
"8686KG//9PLT//11.6CBM\n"
|
||||||
|
"110*110*118CM*7\n"
|
||||||
|
"110*110*80CM*1\n"
|
||||||
|
"110*110*53CM*1\n"
|
||||||
|
"货交上海\n"
|
||||||
|
"看看价格 几号"
|
||||||
|
)
|
||||||
|
guessed = parse_extract_payload(
|
||||||
|
{
|
||||||
|
"transport_mode": "AIR",
|
||||||
|
"fields": {
|
||||||
|
"起运港": "上海",
|
||||||
|
"目的港": "KUL",
|
||||||
|
"品名": "普货",
|
||||||
|
"件数": "9",
|
||||||
|
"毛重": "8686KG",
|
||||||
|
"体积": "11.6CBM",
|
||||||
|
"包装方式": "散货",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
facts = extract_inquiry_snapshot(text, b_result=guessed)["facts"]
|
||||||
|
self.assertEqual(facts.get("提货地址"), "上海")
|
||||||
|
self.assertNotIn("起运港", facts)
|
||||||
|
self.assertEqual(facts.get("目的港"), "KUL")
|
||||||
|
|
||||||
|
def test_huojiao_colon_and_dao_same_as_pickup(self) -> None:
|
||||||
|
for spoken in ("货交:上海", "货交到上海", "货交地:上海"):
|
||||||
|
guessed = parse_extract_payload(
|
||||||
|
{
|
||||||
|
"transport_mode": "AIR",
|
||||||
|
"fields": {"起运港": "上海", "目的港": "KUL", "品名": "普货"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
facts = extract_inquiry_snapshot(
|
||||||
|
f"空运 {spoken} 目的地KUL", b_result=guessed
|
||||||
|
)["facts"]
|
||||||
|
self.assertEqual(facts.get("提货地址"), "上海", spoken)
|
||||||
|
self.assertNotIn("起运港", facts, spoken)
|
||||||
|
|
||||||
|
def test_explicit_route_keeps_origin(self) -> None:
|
||||||
|
text = "SZX-CGK 空运 货交深圳"
|
||||||
|
guessed = parse_extract_payload(
|
||||||
|
{
|
||||||
|
"transport_mode": "AIR",
|
||||||
|
"fields": {"起运港": "SZX", "目的港": "CGK", "品名": "普货"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
facts = extract_inquiry_snapshot(text, b_result=guessed)["facts"]
|
||||||
|
self.assertEqual(facts.get("提货地址"), "深圳")
|
||||||
|
self.assertEqual(facts.get("起运港"), "SZX")
|
||||||
|
self.assertEqual(facts.get("目的港"), "CGK")
|
||||||
|
|
||||||
|
def test_fly_from_city_keeps_origin(self) -> None:
|
||||||
|
text = "从南京飞吉隆坡 空运 货交上海"
|
||||||
|
guessed = parse_extract_payload(
|
||||||
|
{
|
||||||
|
"transport_mode": "AIR",
|
||||||
|
"fields": {"起运港": "南京", "目的港": "吉隆坡", "品名": "普货"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
facts = extract_inquiry_snapshot(text, b_result=guessed)["facts"]
|
||||||
|
self.assertEqual(facts.get("提货地址"), "上海")
|
||||||
|
self.assertEqual(facts.get("起运港"), "南京")
|
||||||
|
self.assertEqual(facts.get("目的港"), "吉隆坡")
|
||||||
|
|
||||||
|
|
||||||
class AirQuoteSheetInquiryOnlyTests(unittest.TestCase):
|
class AirQuoteSheetInquiryOnlyTests(unittest.TestCase):
|
||||||
def test_quote_sheet_keeps_inquiry_fields_only(self) -> None:
|
def test_quote_sheet_keeps_inquiry_fields_only(self) -> None:
|
||||||
"""费用明细、报价条件、落款不能进补问。起运地不能收成操作费。"""
|
"""费用明细、报价条件、落款不能进补问。起运地不能收成操作费。"""
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class LandCopyTest(unittest.TestCase):
|
|||||||
self.assertIn("始发站(必填):广州", text)
|
self.assertIn("始发站(必填):广州", text)
|
||||||
self.assertIn(copy.CLARIFY_PASTE_HINT, text)
|
self.assertIn(copy.CLARIFY_PASTE_HINT, text)
|
||||||
self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text)
|
self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text)
|
||||||
self.assertIn("2.通关口岸(非必填):\n(如:皇岗)", text)
|
self.assertIn("2.通关口岸(非必填):\n(如:友谊关)", text)
|
||||||
self.assertNotIn("体积(必填):(如:", text)
|
self.assertNotIn("体积(必填):(如:", text)
|
||||||
self.assertNotIn(copy.LAND_CONFIRM_TAIL, text)
|
self.assertNotIn(copy.LAND_CONFIRM_TAIL, text)
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class TestSystemExceptionCopy(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
copy.system_exception_impact(copy.STEP_TMS_RELEASE),
|
copy.system_exception_impact(copy.STEP_TMS_RELEASE),
|
||||||
"释放舱位失败,工单状态保持不变",
|
"取消舱位失败,工单状态保持不变",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_alert_title_never_has_test_prefix(self) -> None:
|
def test_alert_title_never_has_test_prefix(self) -> None:
|
||||||
@@ -118,9 +118,9 @@ class TestSystemExceptionCopy(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
text,
|
text,
|
||||||
"工单WO202609210001 空运释放舱位失败。\n"
|
"工单WO202609210001 空运取消舱位失败。\n"
|
||||||
"操作人:吴季林\n"
|
"操作人:吴季林\n"
|
||||||
"释放舱位失败的原因:舱位不存在\n"
|
"取消舱位失败的原因:舱位不存在\n"
|
||||||
"已通知IT运维排查,请稍等。",
|
"已通知IT运维排查,请稍等。",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -409,7 +409,7 @@ class TestConfirmSystemException(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
card = queued[0]["payload"]["template_card"]
|
card = queued[0]["payload"]["template_card"]
|
||||||
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
|
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
|
||||||
self.assertEqual(values["影响范围"], "释放舱位失败,工单状态保持不变")
|
self.assertEqual(values["影响范围"], "取消舱位失败,工单状态保持不变")
|
||||||
|
|
||||||
def test_closed_ticket_not_marked(self) -> None:
|
def test_closed_ticket_not_marked(self) -> None:
|
||||||
self.ledger.transition(work_order_no=self.wo, to_status="已关闭")
|
self.ledger.transition(work_order_no=self.wo, to_status="已关闭")
|
||||||
|
|||||||
+2
-2
@@ -294,7 +294,7 @@ public class InquiryTmsCabinService {
|
|||||||
return fail("release_need_lock", "本单还没有成功锁舱记录");
|
return fail("release_need_lock", "本单还没有成功锁舱记录");
|
||||||
}
|
}
|
||||||
if (!properties.lockConfigured()) {
|
if (!properties.lockConfigured()) {
|
||||||
return fail("lock_disabled", "主账未开放释放");
|
return fail("lock_disabled", "主账未开放取消舱位");
|
||||||
}
|
}
|
||||||
Map<String, Object> req = new LinkedHashMap<>();
|
Map<String, Object> req = new LinkedHashMap<>();
|
||||||
req.put("lockId", lockId);
|
req.put("lockId", lockId);
|
||||||
@@ -363,7 +363,7 @@ public class InquiryTmsCabinService {
|
|||||||
return out;
|
return out;
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
log.warn("tms.release fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
|
log.warn("tms.release fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
|
||||||
return fail("release_fail", firstNonBlank(ex.getMessage(), "释放失败"));
|
return fail("release_fail", firstNonBlank(ex.getMessage(), "取消舱位失败"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,8 +190,8 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
{
|
{
|
||||||
id: "brv10000000000000000000000000024",
|
id: "brv10000000000000000000000000024",
|
||||||
replyCode: "inquiring.air_lock_need_wo",
|
replyCode: "inquiring.air_lock_need_wo",
|
||||||
trigger: "空运锁舱/释放未带工单号",
|
trigger: "空运锁舱/取消舱位未带工单号",
|
||||||
text: "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。",
|
text: "锁舱或取消舱位请带上工单号,例如 WO202609160001取消舱位。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -205,7 +205,7 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
id: "brv10000000000000000000000000026",
|
id: "brv10000000000000000000000000026",
|
||||||
replyCode: "inquiring.air_airline_only_cabin",
|
replyCode: "inquiring.air_airline_only_cabin",
|
||||||
trigger: "只有航线能锁舱或释放",
|
trigger: "只有航线能锁舱或释放",
|
||||||
text: "只有岗位为「航线·空运」的人能锁舱或释放舱位。",
|
text: "只有岗位为「航线·空运」的人能锁舱或取消舱位。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -225,29 +225,29 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
{
|
{
|
||||||
id: "brv10000000000000000000000000029",
|
id: "brv10000000000000000000000000029",
|
||||||
replyCode: "inquiring.air_release_ok",
|
replyCode: "inquiring.air_release_ok",
|
||||||
trigger: "空运释放成功",
|
trigger: "空运取消舱位成功",
|
||||||
text: "工单{{ticket_no}} 已通知TMS执行空运释放舱位成功\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}",
|
text: "工单{{ticket_no}} 已通知TMS执行空运取消舱位成功\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "brv10000000000000000000000000030",
|
id: "brv10000000000000000000000000030",
|
||||||
replyCode: "inquiring.air_release_fail",
|
replyCode: "inquiring.air_release_fail",
|
||||||
trigger: "空运释放失败",
|
trigger: "空运取消舱位失败",
|
||||||
text: "工单{{ticket_no}} 空运释放舱位失败。\n操作人:{{operator}}\n释放舱位失败的原因:{{reason}}\n已通知IT运维排查,请稍等。",
|
text: "工单{{ticket_no}} 空运取消舱位失败。\n操作人:{{operator}}\n取消舱位失败的原因:{{reason}}\n已通知IT运维排查,请稍等。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "brv10000000000000000000000000031",
|
id: "brv10000000000000000000000000031",
|
||||||
replyCode: "inquiring.air_release_deal_blocked",
|
replyCode: "inquiring.air_release_deal_blocked",
|
||||||
trigger: "已成交不能释放舱位",
|
trigger: "已成交不能取消舱位",
|
||||||
text: "释放舱位失败,工单{{ticket_no}}已成交。",
|
text: "取消舱位失败,工单{{ticket_no}}已成交。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "brv10000000000000000000000000032",
|
id: "brv10000000000000000000000000032",
|
||||||
replyCode: "inquiring.air_release_need_lock",
|
replyCode: "inquiring.air_release_need_lock",
|
||||||
trigger: "没有有效锁舱不能释放",
|
trigger: "没有有效锁舱不能取消舱位",
|
||||||
text: "工单{{ticket_no}}还没有有效锁舱,不能释放。",
|
text: "工单{{ticket_no}}还没有有效锁舱,不能取消舱位。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user