空运成交后可在本群继续锁舱;调价同步舱位费用桶;出单按空运段骨架落费并保留陆运车型尺寸;导出文件名乱码修复。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user