空运成交后可在本群继续锁舱;调价同步舱位费用桶;出单按空运段骨架落费并保留陆运车型尺寸;导出文件名乱码修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-14 15:34:24 +08:00
co-authored by Cursor
parent 92bdf56a4d
commit cc8529f569
29 changed files with 1941 additions and 276 deletions
@@ -447,6 +447,7 @@ def _handle_cabin(
chat_id=chat_id,
work_order_no=work_order_no,
allow_terminal=intent == INTENT_RELEASE,
for_lock=intent == INTENT_LOCK,
)
if ticket is None:
return phase
+14 -8
View File
@@ -440,12 +440,17 @@ class HttpLedger:
idem=f"collab-facts:{work_order_no}:{sorted(facts)}",
)
def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
"""空运群激活/切换当前工单。一单一群。"""
def activate_air_group(
self, *, chat_id: str, work_order_no: str, allow_deal: bool = False
) -> dict[str, Any]:
"""空运群激活/切换当前工单。一单一群。allow_deal 只用于已成交后锁舱补参数。"""
body: dict[str, Any] = {"chatId": chat_id, "workOrderNo": work_order_no}
if allow_deal:
body["allowDeal"] = True
return self._post(
"/inquiry/agent/ticket/activateAirGroup",
{"chatId": chat_id, "workOrderNo": work_order_no},
idem=f"air-activate:{chat_id}:{work_order_no}",
body,
idem=f"air-activate:{chat_id}:{work_order_no}:{int(bool(allow_deal))}",
)
def lock_cabin(
@@ -479,9 +484,9 @@ class HttpLedger:
_line = _json.dumps(
{
"sessionId": "e38e4c",
"sessionId": "3ba460",
"runId": "pre-fix",
"hypothesisId": "H-E",
"hypothesisId": "C",
"location": "http_ledger.py:lock_cabin",
"message": "ledger_lock_called",
"data": {
@@ -496,8 +501,9 @@ class HttpLedger:
ensure_ascii=False,
) + "\n"
for _p in (
_Path(r"d:\own\友通达询价智能体(新)\debug-e38e4c.log"),
_Path(r"C:/Users/ytd_extdev_0903/incoming/debug-e38e4c.log"),
_Path(r"d:\own\友通达询价智能体(新)\debug-3ba460.log"),
_Path(r"C:/Users/ytd_extdev_0903/incoming/debug-3ba460.log"),
_Path(r"E:/wwwroot/agent/current/debug-3ba460.log"),
):
try:
_p.parent.mkdir(parents=True, exist_ok=True)
+36 -6
View File
@@ -805,11 +805,13 @@ class MemoryLedger:
return ticket
return None
def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
def activate_air_group(
self, *, chat_id: str, work_order_no: str, allow_deal: bool = False
) -> dict[str, Any]:
"""
空运群当前工单:一单一群,未结案才能激活。
不改六态。
allow_deal 只给已成交后的锁舱补参数:把本群记到这张单上,不改六态。
"""
no = (work_order_no or "").strip()
room = (chat_id or "").strip()
@@ -819,7 +821,9 @@ class MemoryLedger:
return {"ok": False, "error": "ticket_not_found"}
if (ticket.business_line or "").upper() != "AIR":
return {"ok": False, "error": "not_air", "status": ticket.status}
if ticket.status in {"已成交", "未成交", "已关闭"}:
if ticket.status in {"已成交", "未成交", "已关闭"} and not (
allow_deal and ticket.status == "已成交"
):
return {"ok": False, "error": "terminal", "status": ticket.status}
if ticket.collab_chat_id and ticket.collab_chat_id != room:
return {"ok": False, "error": "occupied", "other_chat_id": ticket.collab_chat_id}
@@ -844,17 +848,43 @@ class MemoryLedger:
airline: str = "",
flight_no: str = "",
) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。
"""单测可注入 _cabin_lock_result;默认成功。只有已成交才锁。
纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
"""
_ = sender_id, operator_name, instruction_text, airline, flight_no
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
# 与主账一致:只有已成交才锁。单测注入的失败结果也不能绕过这道门。
if ticket is not None and (ticket.status or "") != "已成交":
st = ticket.status or ""
if st == "协商中":
return {
"ok": False,
"error": "lock_negotiating",
"failReason": "工单还在协商中,成交后才能锁舱",
}
if st == "未成交":
return {
"ok": False,
"error": "lock_lost",
"failReason": "工单已是「未成交」,不能锁舱",
}
if st in {"已关闭", "转人工"}:
return {
"ok": False,
"error": "ticket_closed",
"failReason": f"工单已是「{st}」,不能锁舱",
}
return {
"ok": False,
"error": "lock_need_deal",
"failReason": "请销售先确认工单「已成交」",
}
forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict):
return dict(forced)
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
quote = dict(ticket.quote or {}) if ticket else {}
if not _memory_quote_can_lock(quote):
return {
+59 -5
View File
@@ -903,10 +903,12 @@ _SKIP_FEE_NAMES = frozenset(
)
# 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。
# WO202610130003:协同句常见「空运费, KG, 单价CNY99.00, 数量100, 金额CNY9900」——费名与单价之间夹单位;
# WO202610140008:航线写「增值费_板,单价CNY10…」——下划线单位;漏认会落到金额误抽,出单默认单位「票」。
# 数量/金额前也可能只有空格无逗号。漏匹配会落到 _RE_FEE_LINE,把单价 99 当成行金额、数量被补成 1。
_FEE_UOM = r"KG|KGS|公斤|千克|TON|CBM|柜|票|板数|板|点|[A-Za-z]{1,8}"
_RE_FEE_PRICED = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
r"(?:\s*[,,、]\s*(?:KG|KGS|公斤|千克|TON|CBM|柜|票|板|点|[A-Za-z]{1,8}))?"
rf"(?:(?:\s*[,,、/]\s*|\s+|_)(?P<uom>{_FEE_UOM}))?"
r"\s*[,,、]?\s*单价\s*"
rf"(?:(?P<unit_ccy>{_CCY_TOKEN})\s*)?"
r"(?P<unit>\d+(?:\.\d+)?)"
@@ -916,6 +918,11 @@ _RE_FEE_PRICED = re.compile(
r"(?P<amount>\d+(?:\.\d+)?))?",
re.I,
)
# 句首分段:起运港、增值费_板… → project=起运港(出单落到对应项目区)
_RE_FEE_PROJECT_PREFIX = re.compile(
r"^\s*(?P<project>空运段|空运|起运港|起运地|目的地|目的港|其它|其他)\s*[,,、::]\s*",
re.I,
)
_FULLWIDTH_NUM = str.maketrans("0123456789.", "0123456789.")
@@ -950,14 +957,56 @@ def _currency_of(text: str, hint: str = "") -> str:
return str(hint or "").strip().upper()
def _normalize_fee_project(raw: str) -> str:
"""空运报价单项目列:空运段/起运港/目的地/其它。"""
t = (raw or "").strip()
if t in {"空运", "空运段"}:
return "空运段"
if t in {"起运港", "起运地"}:
return "起运港"
if t in {"目的地", "目的港"}:
return "目的地"
if t in {"其它", "其他"}:
return "其它"
return ""
def _strip_group_bot_mentions(text: str) -> str:
"""
去掉群里 @询价小助手 前缀,避免挡住句首「起运港、」分段标签。
企微协同句常以 @ 开头;不剥的话 project 抽不到,出单只能靠附加费默认「起运港」,
且二次盖价时更容易丢掉分段。
副作用:无。
"""
cleaned = (text or "").replace("@询价小助手", "").replace("询价小助手", "")
return cleaned.strip()
def _split_fee_project_prefix(text: str) -> tuple[str, str]:
"""
拆句首分段标签。例:起运港、增值费_板,单价… → (起运港, 增值费_板,单价…)
副作用:无。
"""
raw = _strip_group_bot_mentions(text or "")
hit = _RE_FEE_PROJECT_PREFIX.match(raw)
if not hit:
return "", raw
project = _normalize_fee_project(hit.group("project") or "")
rest = raw[hit.end() :]
return project, rest
def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]:
"""
按行抽费用:码头操作费100CNY、报关费改为300CNY。
只认带「费」的中文名或短英文费用码,避免把工单号当金额。
句里无币种时继承 baseline_currency(通常是当前 TMS/主账币种),禁止默认 USD。
认「费名, KG, 单价…」与「费名_板,单价…」里的计量单位,写入 unit,禁止出单再默认「票」。
"""
raw = _ascii_amount(text or "")
project, body = _split_fee_project_prefix(text or "")
raw = _ascii_amount(body)
base_ccy = str(baseline_currency or "").strip().upper()
rows: list[dict[str, str]] = []
seen: set[str] = set()
@@ -977,6 +1026,8 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st
"amount": amount,
"currency": line_ccy,
}
if project:
row["project"] = project
for key, val in extra.items():
if val:
row[key] = val
@@ -986,10 +1037,12 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st
# 有「金额」用金额;只有单价时才用单价。禁止把单价误当成这一行的总金额。
amount = (hit.group("amount") or "").strip() or (hit.group("unit") or "").strip()
ccy = hit.group("amt_ccy") or hit.group("unit_ccy") or ""
uom = (hit.groupdict().get("uom") or "").strip()
_add(
(hit.group("name") or "").strip(),
_ascii_amount(amount),
ccy,
unit=uom,
unit_price=_ascii_amount(hit.group("unit") or ""),
quantity=_ascii_amount(hit.group("qty") or ""),
)
@@ -1054,15 +1107,16 @@ def extract_product_quote(
raw = (text or "").strip()
if not raw:
return {}
stripped = raw.replace("@询价小助手", "").replace("询价小助手", "")
stripped = _strip_group_bot_mentions(raw)
compact = stripped.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I):
return {}
band = _parse_weight_band_quote(raw, baseline_currency=base_ccy)
band = _parse_weight_band_quote(stripped or raw, baseline_currency=base_ccy)
if band:
return band
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
scrubbed = _strip_rate_and_band_noise(raw)
# 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。
scrubbed = _strip_rate_and_band_noise(stripped or raw)
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
if rows:
ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy
+107 -17
View File
@@ -1,5 +1,5 @@
"""
空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、锁舱/释放、成交跟进。
空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、成交后再锁舱、成交后仍可取消舱位。
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
@@ -39,6 +39,7 @@ from agent.policy.sea_group_ops import (
logger = logging.getLogger(__name__)
TERMINAL = {"已成交", "未成交", "已关闭", "转人工"}
OPEN_ACTIVATE = {"询价中", "已报价", "协商中"}
AIRLINE_ROLE = "air"
@@ -535,12 +536,13 @@ def resolve_cabin_ticket(
chat_id: str,
work_order_no: str,
allow_terminal: bool,
for_lock: bool = False,
) -> tuple[Optional[object], str]:
"""
锁舱/释放按句中工单号取单,不猜当前单。
未结案:激活并切到本群。已在别的群:occupied,不锁不切。
已成交/未成交:不能再激活,但释放仍要读到这张单(成交拦住、未成交放舱)。
已成交可以锁、也可以取消已锁舱位,不再激活。未成交不能锁,但已有锁仍可取消。
"""
book = flow.ledger
no = (work_order_no or "").strip()
@@ -561,13 +563,18 @@ def resolve_cabin_ticket(
send_group_text(client, chat_id, copy.air_ticket_occupied(no))
return None, "occupied"
if is_terminal(ticket):
st = ticket_status(ticket)
# 锁舱只在已成交之后办。未成交要能读到单,才能回「不能锁」,而不是「不能再激活」。
if for_lock and st in {"已成交", "未成交"}:
# 已成交不再走普通激活,但要把本群记下,下一句补舱位不用再带工单号。
if st == "已成交":
_bind_deal_cabin_room(flow, chat_id=chat_id, work_order_no=no)
return ticket, "deal_loaded" if st == "已成交" else "lost_loaded"
if allow_terminal:
return ticket, "terminal_loaded"
if ticket_status(ticket) == "转人工":
if st == "转人工":
return None, "handoff_ignore"
send_group_text(
client, chat_id, copy.air_ticket_closed(no, ticket_status(ticket))
)
send_group_text(client, chat_id, copy.air_ticket_closed(no, st))
return None, "ticket_closed"
return activate_ticket(flow=flow, chat_id=chat_id, work_order_no=no, speak=False)
@@ -869,9 +876,91 @@ def apply_air_deal(
followup_hours=result.get("negotiate_hours"),
),
)
# 共用回执仍是「已记录为已成交」。催锁只加在空运群,海运陆运不走这里。
if outcome == "已成交" and phase == "done":
prompt_airline_to_lock(flow=flow, ticket=ticket, chat_id=chat_id, work_order_no=no)
return phase
def _bind_deal_cabin_room(flow: Any, *, chat_id: str, work_order_no: str) -> None:
"""已成交锁舱时把工单留在本群。普通激活仍拒绝已成交。失败只记日志,不挡这一句锁舱。"""
book = getattr(flow, "ledger", None)
activator = getattr(book, "activate_air_group", None) if book is not None else None
if not callable(activator):
return
try:
activator(chat_id=chat_id, work_order_no=work_order_no, allow_deal=True)
except TypeError:
logger.warning("主账激活不接受已成交绑群 wo=%s", work_order_no)
except Exception:
logger.exception("已成交锁舱绑群失败 wo=%s", work_order_no)
def prompt_airline_to_lock(
*,
flow: Any,
ticket: object,
chat_id: str,
work_order_no: str = "",
) -> None:
"""
空运单记成已成交后,在协同群点名航线锁舱。
只 @ 岗位为航线·空运的人,不改成交回执。没有群、或多段/海运/陆运,不发。
群消息在调用方线程发出(群回调或私聊成交后的同一轮)。
"""
view = ticket_view(ticket)
no = (work_order_no or ticket_no(ticket)).strip()
room = (chat_id or str(view.get("collab_chat_id") or view.get("collabChatId") or "")).strip()
line = str(view.get("business_line") or view.get("businessLine") or "AIR").strip().upper()
if not no or not room or line != "AIR":
return
_sales_name, airline_names, mention_ids = resolve_air_mentions(flow, ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
air_ids = [uid for uid in mention_ids if uid and uid != sales_id]
send_group_text(
group_client_of(flow),
room,
copy.air_lock_ask_airline(work_order_no=no, airline_names=airline_names),
mention_userids=air_ids,
)
def _block_lock_until_deal(flow: Any, ticket: object, chat_id: str) -> str:
"""
未成交的空运单不许锁。已成交返回空串,调用方继续锁。
询价中/已报价:点名本单销售。协商中、未成交、已关闭各说各的。
转人工沿用静默。不打 TMS。
"""
st = ticket_status(ticket)
if st == "已成交":
return ""
no = ticket_no(ticket)
client = group_client_of(flow)
if st == "未成交":
send_group_text(client, chat_id, copy.air_lock_blocked_lost(no))
return "lock_lost"
if st == "协商中":
send_group_text(client, chat_id, copy.air_lock_blocked_negotiating(no))
return "lock_negotiating"
if st == "转人工":
return "handoff_ignore"
if st == "已关闭":
send_group_text(client, chat_id, copy.air_ticket_closed(no, st))
return "ticket_closed"
sales_name, _airline_names, _mention_ids = resolve_air_mentions(flow, ticket)
view = ticket_view(ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
send_group_text(
client,
chat_id,
copy.air_lock_need_deal(sales_name=sales_name),
mention_userids=[sales_id] if sales_id else None,
)
return "lock_need_deal"
def apply_air_lock(
*,
flow: Any,
@@ -884,15 +973,19 @@ def apply_air_lock(
flight_no: str = "",
) -> str:
"""
航线锁舱。
航线锁舱。只有工单已是「已成交」才打主账。
还没回复成交:点名销售先确认。协商中、未成交各用一句,不催销售改口。
舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。
三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。
上次锁舱失败标过暂停的,航线再锁仍要打 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"
blocked = _block_lock_until_deal(flow, ticket, chat_id)
if blocked:
return blocked
no = ticket_no(ticket)
name = operator_name(flow, sender_id)
view_quote = dict(ticket_view(ticket).get("quote") or {})
@@ -950,12 +1043,15 @@ def apply_air_lock(
is_ok=lambda x: bool((x or {}).get("ok")),
)
if not out.get("ok"):
if str(out.get("error") or "") == "no_quote":
err_code = str(out.get("error") or "")
if err_code == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
# 主账再拦一遍成交状态:只回业务句,不当 TMS 故障。
if err_code in {"lock_need_deal", "lock_negotiating", "lock_lost", "ticket_closed"}:
return _block_lock_until_deal(flow, ticket, chat_id) or err_code
# 主账在出站前发现三项不齐:只请补,不当 TMS 故障,不通知 IT。
reason_text = str(out.get("failReason") or "")
err_code = str(out.get("error") or "")
if err_code == "lock_params_incomplete" or "锁舱参数不完整" in reason_text:
labels = missing or _labels_from_incomplete_reason(reason_text)
_remember_partial_lock(
@@ -1065,9 +1161,6 @@ def apply_air_lock(
if ticket_is_paused(ticket):
clear_system_exception(ledger=flow.ledger, work_order_no=no)
view = ticket_view(ticket)
view["collab_chat_id"] = chat_id
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
return "lock_ok"
def _release_should_push_deal(ticket: object) -> bool:
@@ -1091,11 +1184,8 @@ def apply_air_release(
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。还没锁过的仍拒绝。
no = ticket_no(ticket)
if ticket_status(ticket) == "已成交":
send_group_text(group_client_of(flow), chat_id, copy.air_release_deal_blocked(no))
return "release_deal_blocked"
facts = dict(ticket_view(ticket).get("collab_facts") or {})
lock_id = str(facts.get(_LOCK_ID) or "").strip()
name = operator_name(flow, sender_id)
@@ -2053,6 +2053,16 @@ class AirTextInquiryFlow:
followup_hours=result.get("negotiate_hours"),
)
)
# 私聊点已成交:催锁发到空运协同群。海运陆运、还没建群的不发。
if status == "已成交" and (sess.business_line or "").strip().upper() == "AIR":
from agent.policy.air_group_ops import prompt_airline_to_lock
prompt_airline_to_lock(
flow=self,
ticket=sess,
chat_id=sess.collab_chat_id,
work_order_no=sess.work_order_no,
)
return phase
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
+43 -38
View File
@@ -177,45 +177,50 @@ def finish_deal_outcome(
staff = list(lister(role_code=_role_of(business_line)) or [])
except Exception:
logger.exception("成交匹配负责人失败 wo=%s", no)
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
if not leaders:
summary = "无匹配负责人"
elif not ready:
# 通知卡拼字段失败不能挡住六态「已成交」和群回执(WO001:selected_air_option 非字典)。
try:
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
if not leaders:
summary = "无匹配负责人"
elif not ready:
summary = "通知未发出"
else:
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
summary = "已推:" + "、".join(n for n in names if n)
sender = enqueue or _enqueue_default
payload = copy.deal_notify_wecom_payload(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
text = copy.deal_notify_card(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
continue
try:
ok, info = sender(
touser=uid,
content=text,
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
except Exception:
logger.exception("成交通知组装失败,仍继续入账 wo=%s", no)
summary = "通知未发出"
else:
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
summary = "已推:" + "、".join(n for n in names if n)
sender = enqueue or _enqueue_default
payload = copy.deal_notify_wecom_payload(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
text = copy.deal_notify_card(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
continue
try:
ok, info = sender(
touser=uid,
content=text,
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
extra["deal_notify_summary"] = summary
extra["remark"] = summary
writer = getattr(ledger, "transition", None)
+56 -1
View File
@@ -3420,8 +3420,63 @@ def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
)
def air_lock_ask_airline(*, work_order_no: str, airline_names: list[str] | None = None) -> str:
"""
已成交后点名航线去锁舱。只在空运群另发,不改共用的「已记录为已成交」。
后台 inquiring.air_lock_ask_airline。姓名来自岗位「航线·空运」。
"""
no = (work_order_no or "").strip()
names = [str(n or "").strip() for n in (airline_names or []) if str(n or "").strip()]
at = " ".join(f"@{n}" for n in names) if names else "@航线人员"
return _cabin_copy(
"inquiring.air_lock_ask_airline",
f"{no}请锁舱{at}",
ticket_no=no,
airline_names=at,
)
def air_lock_need_deal(*, sales_name: str) -> str:
"""
销售还没回复成交、未成交或协商中,航线就要锁。点名本单销售。
后台 inquiring.air_lock_need_deal。
"""
name = (sales_name or "").strip() or "销售"
return _cabin_copy(
"inquiring.air_lock_need_deal",
f"请销售@{name}先确认工单「已成交」",
sales_name=name,
)
def air_lock_blocked_negotiating(work_order_no: str) -> str:
"""协商中已经回过,不能再当成没回复。后台 inquiring.air_lock_negotiating。"""
no = (work_order_no or "").strip()
return _cabin_copy(
"inquiring.air_lock_negotiating",
f"工单{no}还在协商中,成交后才能锁舱。",
ticket_no=no,
)
def air_lock_blocked_lost(work_order_no: str) -> str:
"""未成交不能再请销售改口确认已成交。后台 inquiring.air_lock_lost。"""
no = (work_order_no or "").strip()
return _cabin_copy(
"inquiring.air_lock_lost",
f"工单{no}已是「未成交」,不能锁舱。",
ticket_no=no,
)
def air_release_deal_blocked(work_order_no: str) -> str:
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
"""
旧句:已成交不能取消舱位。
现口径是成交并锁舱之后仍可取消,群里不再发这句。后台编码保留,避免旧配置对不上。
"""
no = (work_order_no or "").strip()
return _cabin_copy(
"inquiring.air_release_deal_blocked",
@@ -1753,6 +1753,12 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
_apply_fee_amount(old, amount)
if item.get("currency"):
old["currency"] = item.get("currency")
# 盖价时一并写入单位/数量/分段:否则旧行默认「票」会盖住新抽到的「板」、
# project=起运港 也会丢,出单又落到错段或错单位(WO008/WO009)。
for key in ("unit", "unit_price", "unitPrice", "quantity", "project", "segment"):
val = item.get(key)
if val not in (None, ""):
old[key] = val
rows[idx] = old
out = dict(base or {})
for key, val in incoming.items():
@@ -913,6 +913,11 @@ def _resume_cabin_step(
service = "空运锁舱"
reason = "空运锁舱接口调用失败"
if not (out or {}).get("ok"):
err = str((out or {}).get("error") or "")
# 还没成交、协商中、未成交:业务拒绝,不标 TMS 异常、不通知 IT。
if err in {"lock_need_deal", "lock_negotiating", "lock_lost", "ticket_closed"}:
reply(str((out or {}).get("failReason") or "还不能锁舱"))
return {"ok": False, "error": err}
why = copy.air_cabin_fail_reason(
tms_reason=str((out or {}).get("failReason") or (out or {}).get("error") or ""),
flight_date=str(extra.get("flight_date") or ""),
@@ -144,11 +144,17 @@ def route_codes(
"""
价格明细/方案卡用的航线三字码:有中转写成 ZUH-CAN-CRK。
对不上就不写假中转。
selected_air_option 可能是字典、JSON 串,或只写了舱位编号;
不能直接 dict(字符串),否则成交卡拼航线会整单炸掉(WO001)。
"""
src = dict(option or {})
src = _parse_option_dict(option)
if not src:
q = dict(quote or {})
src = dict(q.get("selected_air_option") or {})
q = dict(quote or {}) if isinstance(quote, dict) else {}
# 先认 camelCase,和主账快照字段对齐。
src = _parse_option_dict(
q.get("selected_air_option") or q.get("selectedAirOption")
)
if not src:
opts = parse_air_options(q)
src = dict(opts[0]) if opts else {}
+160 -18
View File
@@ -461,6 +461,11 @@ def effective_quote(tms: dict[str, Any] | None, overlay: dict[str, Any] | None)
_apply_amount(old, str(item.get("amount") or ""))
if item.get("currency"):
old["currency"] = item.get("currency")
# 与 merge_quote_fees 一致:盖价保留新行的单位/数量/分段,禁止旧默认「票」残留。
for key in ("unit", "unit_price", "unitPrice", "quantity", "project", "segment"):
val = item.get(key)
if val not in (None, ""):
old[key] = val
rows[idx] = old
out = dict(base)
out["fee_rows"] = rows
@@ -763,6 +768,52 @@ def _match_fee_index(name: str, fees: list[dict[str, Any]], used: set[int]) -> i
)
def _fee_air_project(fee: dict[str, Any]) -> str:
"""
空运费用落到哪一段:有 project 跟 project;否则空运费进空运段,其余进起运港。
与主账 mergeAirBucketRowsWithFlatManualFees 兜底一致,避免增值费进不了起运港。
"""
project = str(fee.get("project") or fee.get("segment") or "").strip()
if project in {"空运段", "空运"}:
return "空运段"
if project in {"起运港", "起运地"}:
return "起运港"
if project in {"目的地", "目的港"}:
return "目的地"
if project in {"其它", "其他"}:
return "其它"
name = _fee_title(fee)
if "空运" in name and "增值" not in name and "增点" not in name:
return "空运段"
return "起运港"
def _fee_as_air_charge(fee: dict[str, Any], *, quote: dict[str, Any]) -> dict[str, Any]:
"""扁平 fee_lines 转舱位 departureCharges / feeItems 会读的结构。"""
title = _fee_title(fee)
plain = _plain_amount(str(fee.get("amount") or ""))
unit_price = _plain_amount(
str(fee.get("unit_price") or fee.get("unitPrice") or fee.get("amount") or "")
)
out: dict[str, Any] = {
"chargeName": title,
"feeName": title,
"name": title,
"amount": plain or unit_price,
"unitPrice": unit_price or plain,
"currency": fee.get("currency") or quote.get("currency") or "",
"project": _fee_air_project(fee),
}
unit = str(fee.get("unit") or fee.get("unitName") or "").strip()
if unit:
out["unit"] = unit
qty = str(fee.get("quantity") or fee.get("chargeQuantity") or "").strip()
if qty:
out["quantity"] = qty
return out
def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
raw, was_str = _json_struct(quote.get("feeItems"))
items = [dict(x) for x in raw if isinstance(x, dict)] if isinstance(raw, list) else []
@@ -775,8 +826,20 @@ def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
"currency": fee.get("currency") or quote.get("currency") or "",
}
for fee in fees
if _fee_title(fee)
if _fee_title(fee) and _fee_air_project(fee) == "空运段"
]
if not items:
# 无空运段费目时仍按原逻辑铺全量,避免只有起运附加费时整表空掉
items = [
{
"name": _fee_title(fee),
"feeName": _fee_title(fee),
"amount": _plain_amount(str(fee.get("amount") or "")),
"currency": fee.get("currency") or quote.get("currency") or "",
}
for fee in fees
if _fee_title(fee)
]
else:
used: set[int] = set()
for item in items:
@@ -789,6 +852,22 @@ def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
item["name"] = _fee_title(fees[idx])
if not item.get("feeName"):
item["feeName"] = _fee_title(fees[idx])
# 调价新增的空运段费目也要进 feeItems,否则模板只读舱位桶时看不到
for i, fee in enumerate(fees):
if i in used or _fee_air_project(fee) != "空运段":
continue
title = _fee_title(fee)
if not title:
continue
items.append(
{
"name": title,
"feeName": title,
"amount": _plain_amount(str(fee.get("amount") or "")),
"currency": fee.get("currency") or quote.get("currency") or "",
}
)
used.add(i)
if items:
_put_struct(quote, "feeItems", items, was_str)
@@ -870,6 +949,67 @@ def _stamp_buckets(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
_store_bucket(quote, key, items)
def _stamp_air_bucket_list(
opt: dict[str, Any],
key_primary: str,
key_alt: str,
fees: list[dict[str, Any]],
used: set[int],
*,
projects: set[str],
quote: dict[str, Any],
) -> None:
"""
更新舱位某一费用桶,并把 fee_lines 里多出来的同段费用追加进去。
为何:调价/产品报价常只改扁平明细;模板读 departureCharges,不追加就会缺「增值费」行。
"""
charge_key = key_primary if key_primary in opt or key_alt not in opt else key_alt
if charge_key not in opt and key_primary not in opt and key_alt not in opt:
# 舱位从未带过该桶:只要有待追加费用就新建,否则不动
if not any(i not in used and _fee_air_project(fees[i]) in projects for i in range(len(fees))):
return
charge_key = key_primary
charges, charges_str = [], False
else:
charges, charges_str = _json_struct(opt.get(charge_key))
if not isinstance(charges, list):
charges = []
copied = [dict(x) if isinstance(x, dict) else x for x in charges]
for charge in copied:
if not isinstance(charge, dict):
continue
idx = _match_fee_index(_fee_title(charge), fees, used)
if idx < 0:
continue
used.add(idx)
plain = _plain_amount(str(fees[idx].get("amount") or ""))
_write_price(charge, plain)
# 单价/数量若调价行有值也盖上,模板按单价×数量重算时才对
fee = fees[idx]
up = _plain_amount(str(fee.get("unit_price") or fee.get("unitPrice") or ""))
if up:
charge["unitPrice"] = up
charge["unit_price"] = up
qty = str(fee.get("quantity") or "").strip()
if qty:
charge["quantity"] = qty
unit = str(fee.get("unit") or "").strip()
if unit:
charge["unit"] = unit
for i, fee in enumerate(fees):
if i in used:
continue
if _fee_air_project(fee) not in projects:
continue
title = _fee_title(fee)
if not title:
continue
copied.append(_fee_as_air_charge(fee, quote=quote))
used.add(i)
opt[charge_key] = json.dumps(copied, ensure_ascii=False) if charges_str else copied
def _stamp_air_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
"""空运报价单读舱位 departureCharges 和 salePrice,不读改过的 fee_lines。"""
total_plain = _plain_amount(str(quote.get("total") or ""))
@@ -882,23 +1022,25 @@ def _stamp_air_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None
for opt in options:
if not isinstance(opt, dict):
continue
charge_key = "departureCharges" if "departureCharges" in opt or "departure_charges" not in opt else "departure_charges"
if charge_key not in opt and "departureCharges" not in opt and "departure_charges" not in opt:
charge_key = ""
if charge_key:
charges, charges_str = _json_struct(opt.get(charge_key))
if isinstance(charges, list) and charges:
used: set[int] = set()
copied = [dict(x) if isinstance(x, dict) else x for x in charges]
for charge in copied:
if not isinstance(charge, dict):
continue
idx = _match_fee_index(_fee_title(charge), fees, used)
if idx < 0:
continue
used.add(idx)
_write_price(charge, _plain_amount(str(fees[idx].get("amount") or "")))
opt[charge_key] = json.dumps(copied, ensure_ascii=False) if charges_str else copied
used: set[int] = set()
_stamp_air_bucket_list(
opt,
"departureCharges",
"departure_charges",
fees,
used,
projects={"起运港", "其它"},
quote=quote,
)
_stamp_air_bucket_list(
opt,
"destinationCharges",
"destination_charges",
fees,
used,
projects={"目的地"},
quote=quote,
)
if total_plain:
if opt.get("salePrice") not in (None, ""):
opt["salePrice"] = total_plain
+76 -51
View File
@@ -1055,6 +1055,8 @@ class AirGroupCollabTests(unittest.TestCase):
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已成交")
self.assertTrue(any(f"{wo}请锁舱@HangXian" in t for t in self._texts()))
self.assertTrue(any("已记录为「已成交」" in t for t in self._texts()))
def test_lock_requires_work_order(self) -> None:
phase = handle_air_group(
@@ -1074,8 +1076,9 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "no_quote")
self.assertTrue(any("还没有报价,不能锁舱" in t for t in self._texts()))
self.assertEqual(phase, "lock_need_deal")
self.assertTrue(any("请销售@WuJiLin先确认工单「已成交」" in t for t in self._texts()))
self.assertFalse(any("还没有报价,不能锁舱" in t for t in self._texts()))
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
self.assertFalse(any("执行成功" in t for t in self._texts()))
@@ -1087,7 +1090,7 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "manual",
"source_label": "产品报价",
},
status="已报价",
status="已成交",
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_manual", mid="lm1"),
@@ -1136,7 +1139,7 @@ class AirGroupCollabTests(unittest.TestCase):
},
],
},
status="已报价",
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1167,7 +1170,8 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-23",
}
},
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1217,7 +1221,8 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-24",
}
},
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1257,7 +1262,8 @@ class AirGroupCollabTests(unittest.TestCase):
"total": "CNY 1800",
"source": "manual",
"source_label": "产品报价",
}
},
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1285,7 +1291,8 @@ class AirGroupCollabTests(unittest.TestCase):
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:
def test_deal_then_lock_then_release(self) -> None:
"""成交前不能锁。成交后点名航线,锁成功可取消,不再问一次是否成交。"""
wo = self._create_air()
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l2"),
@@ -1293,36 +1300,64 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
handle_air_group(
self.assertEqual(phase, "lock_need_deal")
self.assertTrue(any("请销售@WuJiLin先确认工单「已成交」" in t for t in self._texts()))
phase = handle_air_group(
self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="l3"),
flow=self.flow,
ledger=self.ledger,
injected_intent="deal_won",
)
self.assertEqual(phase, "done")
self.assertTrue(any(f"{wo}请锁舱@HangXian" in t for t in self._texts()))
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="l4"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_release",
)
self.assertEqual(phase, "release_deal_blocked")
self.assertTrue(any("已成交" in t for t in self._texts()))
def test_lost_can_release(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="r0"),
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l4"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
handle_air_group(
self._msg(sender="sales1", text="未成交", chat="wr_air_1", mid="r1"),
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
self.assertFalse(any("请确认成交状态" in t for t in self._texts()))
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}取消舱位", chat="wr_air_1", mid="l5"),
flow=self.flow,
ledger=self.ledger,
injected_intent="deal_lost",
)
self.assertEqual(phase, "release_ok")
self.assertTrue(any("取消舱位成功" in t for t in self._texts()))
def test_negotiating_and_lost_cannot_lock(self) -> None:
wo_talk = self._create_air(status="协商中")
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo_talk}锁舱", chat="wr_air_talk", mid="ng1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_negotiating")
self.assertTrue(any("还在协商中,成交后才能锁舱" in t for t in self._texts()))
self.assertFalse(any("先确认工单「已成交」" in t for t in self._texts()))
wo_lost = self._create_air(status="未成交")
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo_lost}锁舱", chat="wr_air_lost", mid="ls1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_lost")
self.assertTrue(any("已是「未成交」,不能锁舱" in t for t in self._texts()))
self.assertFalse(any("先确认工单「已成交」" in t for t in self._texts()))
def test_lost_can_release(self) -> None:
"""未成交不能新锁,但改规则前已经锁上的仍可取消。"""
wo = self._create_air(status="未成交")
self.ledger.patch_collab_facts(
work_order_no=wo,
facts={"__air_lock_id": "CA-OLD-LOCK"},
sender_id="air1",
)
before = len(self._texts())
phase = handle_air_group(
@@ -1338,7 +1373,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_release_wording_asks_before_cancel(self) -> None:
"""说出释放舱位只提问。销售回「是」不算,航线确认后才取消。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask", mid="ask0"),
flow=self.flow,
@@ -1370,7 +1405,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
def test_release_wording_can_be_declined(self) -> None:
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask_no", mid="no0"),
flow=self.flow,
@@ -1393,7 +1428,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
def test_cancel_cabin_words_release_immediately(self) -> None:
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_now", mid="now0"),
flow=self.flow,
@@ -1430,7 +1465,7 @@ class AirGroupCollabTests(unittest.TestCase):
"status": "active",
}
)
wo = self._create_air()
wo = self._create_air(status="已成交")
phase = handle_air_group(
self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s2"),
flow=self.flow,
@@ -1439,7 +1474,8 @@ class AirGroupCollabTests(unittest.TestCase):
)
self.assertEqual(phase, "lock_ok")
def test_multi_option_asks(self) -> None:
def test_multi_tms_option_locks_first_until_named(self) -> None:
"""TMS 有多条舱位且没点名时,直接锁第一条,不再追问三项。"""
wo = self._create_air(
quote={
"total": "USD 800",
@@ -1449,7 +1485,8 @@ class AirGroupCollabTests(unittest.TestCase):
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"},
{"optionNo": "AIR-OPT-02", "airlineCode": "MU", "flightNo": "MU5123"},
],
}
},
status="已成交",
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="m1"),
@@ -1457,16 +1494,9 @@ 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-OPT-02", chat="wr_air_1", mid="m2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_pick_option",
)
self.assertEqual(phase, "lock_ok")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-02")
self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-01")
def _file(self, *, sender: str, filename: str, chat: str, mid: str, text: str = "") -> InboundMessage:
return InboundMessage(
@@ -1787,7 +1817,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_lock_fail_tells_operator_and_tms_reason(self) -> None:
"""锁舱失败群里要回操作人和 TMS 原因,不能被系统异常兜底盖掉。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
@@ -1810,25 +1840,20 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertNotIn("与工单不匹配", joined)
def test_lock_fail_does_not_block_airline_quote(self) -> None:
"""锁舱失败后,航线再报手工价必须收下,不能回 TMS 接口异常。"""
"""成交前锁舱不会打 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.assertEqual(phase, "lock_need_deal")
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_lock_quote", mid="lq2"),
@@ -1843,7 +1868,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
@@ -1863,7 +1888,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_paused_lock_retries_and_shows_tms_reason(self) -> None:
"""上次锁舱失败标了系统异常后,再锁仍要打 TMS,不能只回兜底。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
self.ledger.mark_system_exception(
work_order_no=wo,
system_exception="TMS",
@@ -1937,7 +1962,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_release_fail_tells_operator_and_tms_reason(self) -> None:
"""释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_rl_fail", mid="rf0"),
flow=self.flow,
@@ -65,6 +65,32 @@ class AirRouteOptionsTests(unittest.TestCase):
self.assertTrue(needs_route_pick(quote))
self.assertEqual(len(parse_air_options(quote)), 2)
def test_route_codes_tolerates_string_selected_option(self) -> None:
"""主账若把 selected_air_option 存成 JSON 串或编号串,不能再 dict(字符串) 炸掉成交卡。"""
quote_json = {
"selected_air_option": (
'{"originAirportCode":"NKG","destinationAirportCode":"KUL"}'
),
}
self.assertEqual(
route_codes(None, {"起运港": "南京", "目的港": "吉隆坡"}, quote_json),
"NKG-KUL",
)
quote_code = {
"selected_air_option": "AIR-OPT01",
"airOptions": [
{
"optionNo": "AIR-OPT01",
"originAirportCode": "SZX",
"destinationAirportCode": "CGK",
}
],
}
self.assertEqual(
route_codes(None, {"起运港": "深圳", "目的港": "雅加达"}, quote_code),
"SZX-CGK",
)
def test_select_writes_origin(self) -> None:
option = {"originAirportCode": "SZX", "destinationAirportCode": "KUL"}
facts = apply_selected_option({"目的港": "吉隆坡"}, option)
@@ -1392,8 +1392,41 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["空运费"].get("amount") or ""), "9900.00")
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
self.assertEqual(str(rows["空运费"].get("quantity") or ""), "100")
self.assertEqual(str(rows["空运费"].get("unit") or "").upper(), "KG")
self.assertEqual(str(rows["空运费"].get("project") or ""), "空运段")
self.assertEqual(str(quote.get("total") or ""), "9900.0")
def test_air_origin_fee_underscore_unit_board(self) -> None:
"""WO008:起运港、增值费_板,单价… — 单位是板,不能落成票;项目是起运港。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"起运港、增值费_板,单价CNY10,数量100,金额CNY1000@询价小助手",
business_line="AIR",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("增值费", rows)
self.assertEqual(str(rows["增值费"].get("unit") or ""), "板")
self.assertEqual(str(rows["增值费"].get("unit_price") or ""), "10")
self.assertEqual(str(rows["增值费"].get("quantity") or ""), "100")
self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000")
self.assertEqual(str(rows["增值费"].get("project") or ""), "起运港")
self.assertEqual(str(quote.get("total") or ""), "1000.0")
def test_air_origin_fee_mention_prefix_keeps_project_and_unit(self) -> None:
"""WO009:句首 @询价小助手 不能挡住起运港分段;逗号单位「板」不能丢。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"@询价小助手 起运港、增值费,板,单价CNY10,数量100,金额CNY1000",
business_line="AIR",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("增值费", rows)
self.assertEqual(str(rows["增值费"].get("unit") or ""), "板")
self.assertEqual(str(rows["增值费"].get("project") or ""), "起运港")
self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000")
def test_fee_line_text_is_a_product_quote(self) -> None:
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
from agent.llm.extract_text import extract_product_quote
+1 -1
View File
@@ -495,7 +495,7 @@ class TestConfirmSystemException(unittest.TestCase):
"quoteId": "QT-AIR-1",
"airOptions": [{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}],
},
to_status="已报价",
to_status="已成交",
)
self.ledger.mark_system_exception(
work_order_no=air_wo,