Files
inquiry_robot/inquiry-agent/tests/test_air_route_options.py
T
jillion886andCursor 9ffba70741 陆运补问把货物品名放进选项清单,并按销售原文回显已填项。
空着的运输分类不再被菜单第一项认成已填。空运报价改为货物信息加价格方案,锁舱成功回执补起运地、目的地、航司和航班日期。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 17:39:54 +08:00

633 lines
25 KiB
Python

"""
空运线路报价:一条直接展示,多条要选;航线带中转;费用取该线路回包。
"""
from __future__ import annotations
import json
import os
import sys
import unittest
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import AirTextInquiryFlow
from agent.channel.h5.air_route_page import render_air_route_page
from agent.schema.air_route_options import (
apply_selected_option,
needs_route_pick,
option_fee_lines,
parse_air_options,
project_group_air_quote,
project_selected_quote,
route_codes,
route_line,
)
class AirRouteOptionsTests(unittest.TestCase):
def test_single_with_transfer_line(self) -> None:
quote = {
"airOptions": [
{
"originAirportCode": "CAN",
"transferAirportCode": "SHA",
"destinationAirportCode": "LAX",
"salePrice": 110,
"currency": "CNY",
"departureCharges": [
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 110}
],
}
]
}
opts = parse_air_options(quote)
self.assertEqual(len(opts), 1)
self.assertFalse(needs_route_pick(quote))
self.assertEqual(route_line(opts[0], {}), "广州-上海-洛杉矶")
self.assertEqual(route_codes(opts[0], {}), "CAN-SHA-LAX")
fees = option_fee_lines(opts[0], quote)
self.assertEqual(fees[0][0], "运输费")
self.assertIn("110", fees[0][1])
def test_multi_needs_pick(self) -> None:
quote = {
"isMulti": "2",
"airOptions": [
{"originAirportCode": "ZUH", "transferAirportCode": "COC", "destinationAirportCode": "CRK", "salePrice": 183370},
{"originAirportCode": "ZUH", "transferAirportCode": "ABC", "destinationAirportCode": "CRK", "salePrice": 193370},
],
}
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)
self.assertEqual(facts["起运港"], "深圳")
self.assertEqual(facts["originCode"], "SZX")
def test_multi_quote_opens_h5_then_select_writes_origin(self) -> None:
flow = AirTextInquiryFlow(MemoryLedger())
replies: list[str] = []
extras: list[dict] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
extras.append(dict(extra or {}))
facts = {
"目的港": "克拉克",
"品名": "茶叶",
"件数": "10",
"毛重": "100",
"体积": "2",
"包装方式": "托盘",
"报价日期": "2026-09-16",
"tms_multi": "1",
}
phase = flow.on_text(
sender_id="u_multi",
text="空运",
reply=reply,
injected_facts=facts,
injected_mode="AIR",
)
self.assertEqual(phase, "wait_confirm")
phase = flow.on_text(sender_id="u_multi", text="确定", reply=reply)
self.assertEqual(phase, "wait_air_route")
self.assertTrue(any("已查到" in x and "条线路" in x for x in replies))
phase = flow.select_air_route(sender_id="u_multi", index=1, reply=reply)
self.assertEqual(phase, "wait_collab")
sess = flow.session_of("u_multi")
self.assertEqual(sess.facts.get("起运港"), "深圳")
self.assertIn("SZX-CRK", "\n".join(replies[-2:]))
def test_hangzhou_query_uses_airport_and_skips_other_cities(self) -> None:
"""写了杭州就不能按目的地把南京、深圳、郑州拿来选。"""
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.air_text_flow import AirTextInquiryFlow
from agent.schema.tms_air_query import assemble_air_query
ready = assemble_air_query({"起运港": "杭州", "目的港": "吉隆坡"})
self.assertEqual(ready["facts"].get("originCode"), "HGH")
self.assertEqual(ready["facts"].get("destinationCode"), "KUL")
class Ledger(MemoryLedger):
def query_tms(self, **kwargs):
out = super().query_tms(**kwargs)
quote = out["quote"]
quote["total"] = "1750.35"
quote["currency"] = "CNY"
quote["airOptions"] = [
{"originAirportCode": "NKG", "destinationAirportCode": "KUL", "salePrice": 1750.35, "currency": "CNY"},
{"originAirportCode": "SZX", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"},
{"originAirportCode": "CGO", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"},
]
return out
flow = AirTextInquiryFlow(Ledger())
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
facts = {
"起运港": "杭州",
"目的港": "吉隆坡",
"品名": "化妆品",
"件数": "12",
"毛重": "21",
"体积": "1",
"包装方式": "散货",
"报价日期": "2026-09-16",
}
self.assertEqual(
flow.on_text(sender_id="u_hgh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"),
"wait_confirm",
)
phase = flow.on_text(sender_id="u_hgh", text="确定", reply=reply)
self.assertEqual(phase, "tms_miss")
self.assertFalse(any("条线路" in x for x in replies))
self.assertEqual(flow.session_of("u_hgh").facts.get("originCode"), "HGH")
def test_stated_origin_quotes_directly(self) -> None:
"""起运地已经是珠海时,即使 TMS 还带回别的城市,也不再让销售选线。"""
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.air_text_flow import AirTextInquiryFlow
flow = AirTextInquiryFlow(MemoryLedger())
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
facts = {
"起运港": "珠海",
"目的港": "克拉克",
"品名": "茶叶",
"件数": "10",
"毛重": "100",
"体积": "2",
"包装方式": "托盘",
"报价日期": "2026-09-16",
"tms_multi": "1",
}
flow.on_text(sender_id="u_zuh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR")
phase = flow.on_text(sender_id="u_zuh", text="确定", reply=reply)
self.assertEqual(phase, "wait_collab")
self.assertFalse(any("条线路" in x for x in replies))
self.assertEqual(flow.session_of("u_zuh").facts.get("起运港"), "珠海")
def test_reselect_after_adopt_voids_old_file(self) -> None:
"""采用后再改选:旧报价单作废,回到方案卡重新采用。"""
ledger = MemoryLedger()
flow = AirTextInquiryFlow(ledger)
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
facts = {
"目的港": "克拉克",
"品名": "茶叶",
"件数": "10",
"毛重": "100",
"体积": "2",
"包装方式": "托盘",
"报价日期": "2026-09-16",
"tms_multi": "1",
}
phase = flow.on_text(
sender_id="u_void",
text="空运",
reply=reply,
injected_facts=facts,
injected_mode="AIR",
)
self.assertEqual(phase, "wait_confirm")
self.assertEqual(
flow.on_text(sender_id="u_void", text="确定", reply=reply),
"wait_air_route",
)
self.assertEqual(flow.select_air_route(sender_id="u_void", index=0, reply=reply), "wait_collab")
self.assertEqual(
flow.on_button(sender_id="u_void", action=copy.BTN_SKIP_COLLAB, reply=reply),
"wait_adopt",
)
self.assertEqual(
flow.on_button(sender_id="u_void", action=copy.BTN_ADOPT_EXCEL, reply=reply),
"wait_deal",
)
wo = flow.session_of("u_void").work_order_no
self.assertTrue(ledger.get_ticket(work_order_no=wo).quote_file_ready)
phase = flow.select_air_route(sender_id="u_void", index=1, reply=reply)
self.assertEqual(phase, "wait_adopt")
self.assertFalse(ledger.get_ticket(work_order_no=wo).quote_file_ready)
view = ledger.get_for_agent(work_order_no=wo, sender_id="u_void")
self.assertEqual(view.get("wait_phase"), "wait_adopt")
self.assertEqual(flow.session_of("u_void").facts.get("起运港"), "深圳")
self.assertTrue(any("报价方案" in x or "生成Excel" in x for x in replies[-3:]))
def test_empty_route_fees_do_not_borrow_other_quote_lines(self) -> None:
"""TMS 该线路费用列表为空时,禁止套整单或其他线路的空运费。"""
quote = {
"total": "183370.00",
"freight": "183370.00",
"currency": "CNY",
"fee_lines": [
{
"name": "空运费",
"amount": "183370.00",
"currency": "CNY",
"unit_price": "11.00",
}
],
"airOptions": [
{
"originAirportCode": "ZUH",
"transferAirportCode": "CHG",
"destinationAirportCode": "CRK",
"salePrice": 183370,
"currency": "CNY",
"departureCharges": [
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 11},
{"chargeName": "提送货费", "currency": "CNY", "unitPrice": 6},
{"chargeName": "人工费", "currency": "CNY", "unitPrice": 123},
],
},
{
"originAirportCode": "SZX",
"transferAirportCode": "",
"destinationAirportCode": "CRK",
"salePrice": 200040,
"currency": "CNY",
"departureCharges": [],
},
],
}
opts = parse_air_options(quote)
zuh = option_fee_lines(opts[0], quote)
self.assertEqual([name for name, _ in zuh], ["运输费", "提送货费", "人工费"])
szx = option_fee_lines(opts[1], quote)
self.assertEqual(szx, [])
html = render_air_route_page(
{"work_order_no": "WO202609220009", "facts": {"目的港": "克拉克"}, "quote": quote}
)
self.assertIn("运输费", html)
self.assertIn("深圳-克拉克", html)
self.assertNotIn("空运费", html)
self.assertIn("200040", html)
self.assertIn("本线路未返回费用明细", html)
self.assertIn("直达", html)
self.assertIn("中转", html)
projected = project_selected_quote(quote, opts[1])
self.assertEqual(projected.get("fee_lines"), [])
self.assertIn("200040", str(projected.get("total") or ""))
self.assertEqual(copy.quote_fee_lines(projected), [])
detail = copy.sea_quote_detail_text(
work_order_no="WO202609220009",
quote=projected,
business_line="AIR",
)
self.assertNotIn("空运费", detail)
self.assertIn("费用合计", detail)
self.assertIn("200040", detail)
def test_group_air_quote_projects_route_fees_and_drops_total_alias(self) -> None:
"""群摘要投影:该线路分项留下,空运费=总价丢掉。"""
quote = {
"total": "183370.0",
"freight": "183370.0",
"currency": "CNY",
"fee_lines": [
{"name": "空运费", "amount": "183370.00", "currency": "CNY"},
],
"airOptions": [
{
"originAirportCode": "ZUH",
"transferAirportCode": "CHG",
"destinationAirportCode": "CRK",
"salePrice": 183370,
"currency": "CNY",
"departureCharges": [
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0},
{"chargeName": "提送货费", "currency": "CNY", "unitPrice": 6.0},
{"chargeName": "人工费", "currency": "CNY", "unitPrice": 123.0},
],
}
],
}
projected = project_group_air_quote(quote, {"originCode": "ZUH"})
names = [str(x.get("name") or "") for x in (projected.get("fee_lines") or [])]
self.assertEqual(names, ["运输费", "提送货费", "人工费"])
self.assertNotIn("空运费", names)
self.assertEqual(copy.quote_fee_lines(projected)[0][0], "运输费")
def test_air_detail_text_hides_eta_keeps_valid_until(self) -> None:
"""空运没有时效;价格明细只留报价有效期。"""
text = copy.sea_quote_detail_text(
work_order_no="WO202609220013",
quote={
"source_label": "TMS标准报价",
"selected_air_option": {
"originAirportCode": "NKG",
"destinationAirportCode": "KUL",
},
"fee_lines": [
{"name": "增点费", "amount": "100.0", "currency": "CNY"},
{"name": "拆板费", "amount": "100.0", "currency": "USD"},
],
"total": "5001.0",
"currency": "CNY",
"eta": "3天",
"valid_until": "2026-09-30",
},
business_line="AIR",
)
self.assertEqual(
text,
"\n".join(
[
"工单号:WO202609220013",
"运输方式:空运",
"起运地:-",
"目的地:-",
"航司:-",
"品名:-",
"件数:-",
"重量(KG):-",
"体积(CBM):-",
"包装类型:-",
"计费重:-",
"货型:-",
"报价日期:-",
"",
"价格方案如下:",
"增点费:CNY 100.0",
"拆板费:USD 100.0",
"备注:-",
"费用合计:CNY 5001.0",
"报价有效期:2026-09-30",
]
),
)
self.assertNotIn("时效", text)
self.assertNotIn("TMS标准报价", text)
self.assertNotIn("NKG-KUL", text)
def test_air_detail_text_uses_ticket_facts_and_tms_price_shape(self) -> None:
"""货物信息用工单,价格方案按单价/按票/只有金额三种写法。"""
text = copy.sea_quote_detail_text(
work_order_no="WO202610090040",
facts={
"起运地": "NKG",
"目的地": "KUL",
"品名": "普货",
"件数": "800箱",
"毛重": "8300KG",
"体积": "39CBM",
"包装类型": "散货",
"报价日期": "2026-10-09",
},
quote={
"currency": "CNY",
"total": "87150.0",
"valid_until": "2026-10-31",
"remark": "其他杂费如有实报实销,不含续页;续页50/票",
"airlineCode": "TH",
"chargeableWeightKg": "8300",
"cargoType": "1:212",
"fee_lines": [
{
"name": "空运费",
"currency": "CNY",
"unit": "KG",
"unitPrice": 11,
"amount": "91300",
},
{"name": "文件费", "currency": "CNY", "unit": "票", "amount": "300"},
{"name": "报关费", "currency": "CNY", "unit": "/票", "unitPrice": "250"},
{"name": "增点费", "currency": "CNY", "amount": "100.0"},
],
},
business_line="AIR",
)
self.assertIn("工单号:WO202610090040", text)
self.assertIn("起运地:NKG", text)
self.assertIn("目的地:KUL", text)
self.assertIn("航司:TH", text)
self.assertIn("件数:800箱", text)
self.assertIn("重量(KG):8300KG", text)
self.assertIn("体积(CBM):39CBM", text)
self.assertIn("计费重:8300KG", text)
self.assertIn("货型:1:212", text)
self.assertIn("报价日期:2026-10-09", text)
self.assertIn("价格方案如下:", text)
self.assertIn("空运费:单价CNY11/KG", text)
self.assertIn("文件费:CNY300/票", text)
self.assertIn("报关费:CNY250/票", text)
self.assertIn("增点费:CNY 100.0", text)
self.assertIn("备注:其他杂费如有实报实销,不含续页;续页50/票", text)
self.assertIn("费用合计:CNY 87150.0", text)
self.assertIn("报价有效期:2026-10-31", text)
self.assertNotIn("询价字段", text)
def test_h5_page_lists_route_and_fees(self) -> None:
from agent.channel.h5.air_route_page import render_air_route_page
from agent.channel.h5.air_route_store import build_air_route_url
html = render_air_route_page(
{
"work_order_no": "WO202609220001",
"facts": {"目的港": "克拉克"},
"quote": {
"airOptions": [
{
"originAirportCode": "ZUH",
"transferAirportCode": "CAN",
"destinationAirportCode": "CRK",
"salePrice": 183370,
"currency": "CNY",
"departureCharges": [
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 180000}
],
}
]
},
}
)
self.assertIn("珠海-广州-克拉克", html)
self.assertIn("运输费", html)
self.assertIn("总销售价", html)
self.assertIn("class='opt'", html)
self.assertIn("name='index'", html)
self.assertNotIn("userid", html.lower())
self.assertNotIn("sender_id", html)
url = build_air_route_url("tok-abc")
self.assertIn("/air-routes/tok-abc", url)
self.assertNotIn("userid", url)
def test_ok_page_auto_closes_wecom_h5(self) -> None:
from agent.channel.h5.air_route_page import expired_air_route_page, render_air_route_ok
ok = render_air_route_ok()
dead = expired_air_route_page()
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
mobile = ok.split("function closeMobile()")[1].split("function closeDesktop()")[0]
desktop = ok.split("function closeDesktop()")[1].split("function boot()")[0]
self.assertNotIn("window.close()", mobile)
self.assertIn("window.close()", desktop)
self.assertNotIn("closeWindow", dead)
def test_blank_date_always_opens_cabin_page_and_writes_flight_date(self) -> None:
"""没填日期:1 条舱位也要进 H5;选定后用该条总价,航班日期写回,不再查价。"""
from agent.schema.air_route_options import sort_cabin_options
later = {
"originAirportCode": "PVG",
"destinationAirportCode": "LAX",
"flightDate": "2026-10-21",
"schedule": "D1",
"airlineName": "东航",
"salePrice": 1000,
"currency": "CNY",
}
earlier = {
"originAirportCode": "PVG",
"destinationAirportCode": "LAX",
"flightDate": "2026-10-20",
"schedule": "D234567",
"airlineName": "南航",
"salePrice": 3200,
"currency": "CNY",
}
ordered = sort_cabin_options([later, earlier])
self.assertEqual(option_flight_date_of(ordered[0]), "2026-10-20")
class Ledger(MemoryLedger):
def __init__(self) -> None:
super().__init__()
self.queries = 0
def query_tms(self, **kwargs):
self.queries += 1
out = super().query_tms(**kwargs)
out["quote"]["airOptions"] = [later, earlier]
return out
ledger = Ledger()
flow = AirTextInquiryFlow(ledger)
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"件数": "3",
"毛重": "180",
"体积": "2.5",
"包装方式": "纸箱",
}
self.assertEqual(
flow.on_text(sender_id="u_cabin", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"),
"wait_confirm",
)
self.assertIn("报价日期(非必填):", replies[-1])
self.assertNotIn("报价日期:2026", replies[-1])
self.assertEqual(flow.on_text(sender_id="u_cabin", text="确定", reply=reply), "wait_air_route")
self.assertEqual(ledger.queries, 1)
self.assertTrue(any("条舱位" in x for x in replies))
self.assertFalse(any("条线路" in x for x in replies))
self.assertEqual(flow.select_air_route(sender_id="u_cabin", index=0, reply=reply), "wait_collab")
self.assertEqual(ledger.queries, 1)
sess = flow.session_of("u_cabin")
self.assertEqual(sess.facts.get("报价日期"), "2026-10-20")
self.assertIn("3200", str(sess.quote.get("total")))
def test_cabin_page_shows_six_fields_and_total_only(self) -> None:
html = render_air_route_page(
{
"cabin_pick": True,
"work_order_no": "WO202610130001",
"facts": {},
"quote": {
"airOptions": [
{
"originAirportCode": "PVG",
"destinationAirportCode": "LAX",
"flightDate": "2026-10-20",
"schedule": "D234567",
"airlineName": "南航",
"salePrice": 3200,
"currency": "CNY",
"departureCharges": [
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 11}
],
}
]
},
}
)
self.assertIn("选择舱位", html)
self.assertIn("起运地", html)
self.assertIn("目的地", html)
self.assertIn("航班日期", html)
self.assertIn("2026-10-20", html)
self.assertIn("班期", html)
self.assertIn("D234567", html)
self.assertIn("航司", html)
self.assertIn("南航", html)
self.assertIn("3200", html)
self.assertNotIn("运输费", html)
self.assertNotIn("userid", html.lower())
def option_flight_date_of(option: dict) -> str:
from agent.schema.air_route_options import option_flight_date
return option_flight_date(option)
if __name__ == "__main__":
unittest.main()