sophhub 0.4.70 → 0.4.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
import os
|
|
10
10
|
import sys
|
|
11
11
|
import time
|
|
12
|
+
from datetime import datetime, timedelta
|
|
12
13
|
|
|
13
14
|
import requests
|
|
14
15
|
|
|
@@ -355,6 +356,157 @@ def pick_flight_and_cabin(
|
|
|
355
356
|
|
|
356
357
|
# ---------- 创建订单(常规) ----------
|
|
357
358
|
|
|
359
|
+
def merge_verified_prices(
|
|
360
|
+
flight_detail: dict,
|
|
361
|
+
cabin: dict,
|
|
362
|
+
vdata: dict,
|
|
363
|
+
sale_price: float,
|
|
364
|
+
departure_tax: float,
|
|
365
|
+
fuel_tax: float,
|
|
366
|
+
) -> tuple[dict, dict]:
|
|
367
|
+
"""把验价得到的实时价格覆盖到 flight/cabin 的副本上,供创单使用。
|
|
368
|
+
|
|
369
|
+
创单的 productInfo 由这两个对象构造。若不覆盖,priceInfos/segmentInfos 写的是搜索
|
|
370
|
+
缓存的旧价格,而同一个请求里的 estimatedTotal 与 shoppingCode 用的是验价后的新值——
|
|
371
|
+
验价返回 10301(票价变动)或 isUpdatePrice=true 时,这种自相矛盾会让供应商拒单,
|
|
372
|
+
订单留痕也会与实际成交价不符。
|
|
373
|
+
|
|
374
|
+
只改价格相关字段,其余字段(cabinCode、flightNo、subCabinCode 等)原样保留;
|
|
375
|
+
入参对象不被修改。
|
|
376
|
+
"""
|
|
377
|
+
merged_flight = dict(flight_detail)
|
|
378
|
+
merged_flight["departureTax"] = departure_tax
|
|
379
|
+
merged_flight["fuelTax"] = fuel_tax
|
|
380
|
+
|
|
381
|
+
merged_cabin = dict(cabin)
|
|
382
|
+
merged_cabin["salePrice"] = sale_price
|
|
383
|
+
face_price = vdata.get("facePrice")
|
|
384
|
+
if face_price is not None:
|
|
385
|
+
merged_cabin["facePrice"] = face_price
|
|
386
|
+
return merged_flight, merged_cabin
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# 接口文档 6.4:SegmentInfo / RefundChangeDetail 的字段。缺失的取值一律不传,
|
|
390
|
+
# 不用 null 顶替——那会把"没有这个数据"变成"这个字段是空的"。
|
|
391
|
+
_FULL_PRICE_FIELD_BY_GRADE = {"Y": "yPrice", "C": "cPrice", "F": "fPrice"}
|
|
392
|
+
_REFUND_CHANGE_FIELDS = (
|
|
393
|
+
"baggageNum", "baggageKg", "baggage", "changeAmountList", "endorseRule",
|
|
394
|
+
"refundHeaders", "changeHeaders", "refundAmountList", "remark",
|
|
395
|
+
"changeRule", "refundRule",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _text_or_none(value):
|
|
400
|
+
"""只接受非空字符串;其它类型(含对象)一律视为无值。"""
|
|
401
|
+
if isinstance(value, str):
|
|
402
|
+
value = value.strip()
|
|
403
|
+
return value or None
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _stop_city_code(flight_detail: dict):
|
|
408
|
+
"""经停城市三字码。
|
|
409
|
+
|
|
410
|
+
供应商把经停信息放在对象里(文档 6.1 `flightStopOver: FlightStopOver`,实测形如
|
|
411
|
+
``{"cityName": "成都", "stopCityCode": "CTU", "stopDuration": "03:30"}``),非经停航班
|
|
412
|
+
为 null;这里兼容"直接给字符串"的写法,两者取不到值就返回 None(不传该字段)。
|
|
413
|
+
"""
|
|
414
|
+
stop_over = flight_detail.get("flightStopOver")
|
|
415
|
+
if isinstance(stop_over, dict):
|
|
416
|
+
return _text_or_none(stop_over.get("stopCityCode"))
|
|
417
|
+
return _text_or_none(stop_over)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _full_price_of(flight_detail: dict, cabin: dict):
|
|
421
|
+
"""舱位全价:供应商在 fullPriceCabin 里按舱位等级给出(Y→yPrice / C→cPrice / F→fPrice)。"""
|
|
422
|
+
full = flight_detail.get("fullPriceCabin") or {}
|
|
423
|
+
return full.get(_FULL_PRICE_FIELD_BY_GRADE.get((cabin.get("grade") or "").upper(), ""))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _refund_change_detail(refund_change: dict):
|
|
427
|
+
"""退改规则:按文档 6.4 的 RefundChangeDetail 结构取字段。"""
|
|
428
|
+
if not isinstance(refund_change, dict):
|
|
429
|
+
return None
|
|
430
|
+
detail = {k: refund_change.get(k) for k in _REFUND_CHANGE_FIELDS
|
|
431
|
+
if refund_change.get(k) is not None}
|
|
432
|
+
return detail or None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _arrival_date(from_date: str, cross_day) -> str:
|
|
436
|
+
"""到达日期:跨天航班算次日,否则与起飞同一天(文档要求必填)。"""
|
|
437
|
+
if not from_date or not cross_day:
|
|
438
|
+
return from_date
|
|
439
|
+
try:
|
|
440
|
+
return (datetime.strptime(from_date, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
|
|
441
|
+
except ValueError:
|
|
442
|
+
return from_date
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def build_product_info(
|
|
446
|
+
flight_detail: dict,
|
|
447
|
+
cabin: dict,
|
|
448
|
+
from_city: str,
|
|
449
|
+
to_city: str,
|
|
450
|
+
from_date: str,
|
|
451
|
+
passenger_type: str = "ADU",
|
|
452
|
+
) -> dict:
|
|
453
|
+
"""构造创单 productInfo(接口文档 6.4)。
|
|
454
|
+
|
|
455
|
+
字段覆盖文档里 SegmentInfo / RefundChangeDetail / PriceInfo 的必填项;供应商未返回的
|
|
456
|
+
取值直接不传(例如经停城市、到达航站楼在非经停/无数据时为空),不用 null 顶替。
|
|
457
|
+
|
|
458
|
+
供应商用它记录订单的产品信息;网关侧也据此判定订单的价格类型并写入订单留痕
|
|
459
|
+
(price_type / price_type_reason)。不带这段时留痕只能记 UNKNOWN——服务端
|
|
460
|
+
没有别的可信来源。
|
|
461
|
+
"""
|
|
462
|
+
segment = {
|
|
463
|
+
"aircraftType": (flight_detail.get("aircraft") or {}).get("code"),
|
|
464
|
+
"cabinClass": cabin.get("grade"),
|
|
465
|
+
"cabinCode": cabin.get("code"),
|
|
466
|
+
"cabinFullPrice": _full_price_of(flight_detail, cabin),
|
|
467
|
+
"cabinType": cabin.get("type"),
|
|
468
|
+
"carrierName": (flight_detail.get("carrier") or {}).get("name"),
|
|
469
|
+
"departureTax": flight_detail.get("departureTax"),
|
|
470
|
+
"facePrice": cabin.get("facePrice"),
|
|
471
|
+
"flightDuration": flight_detail.get("flightDuration"),
|
|
472
|
+
"flightNo": flight_detail.get("flightNo"),
|
|
473
|
+
"fromAirportCode": flight_detail.get("fromAirportCode"),
|
|
474
|
+
"fromAirportName": flight_detail.get("fromAirportName"),
|
|
475
|
+
"fromAirportNameEn": flight_detail.get("fromAirportEn"),
|
|
476
|
+
"fromCityCode": from_city,
|
|
477
|
+
"fromCityName": flight_detail.get("fromCityCN"),
|
|
478
|
+
"fromDate": from_date,
|
|
479
|
+
"fromTerminal": flight_detail.get("fromTerminal"),
|
|
480
|
+
"fromTime": flight_detail.get("fromTime"),
|
|
481
|
+
"fuelTax": flight_detail.get("fuelTax"),
|
|
482
|
+
"productType": cabin.get("productType"),
|
|
483
|
+
"refundChangeDetail": _refund_change_detail(cabin.get("refundChange")),
|
|
484
|
+
"stopCityCode": _stop_city_code(flight_detail),
|
|
485
|
+
"toAirportCode": flight_detail.get("toAirportCode"),
|
|
486
|
+
"toAirportName": flight_detail.get("toAirportName"),
|
|
487
|
+
"toAirportNameEn": flight_detail.get("toAirportEn"),
|
|
488
|
+
"toCityCode": to_city,
|
|
489
|
+
"toCityName": flight_detail.get("toCityCN"),
|
|
490
|
+
"toDate": _arrival_date(from_date, flight_detail.get("crossDay")),
|
|
491
|
+
"toTerminal": flight_detail.get("toTerminal"),
|
|
492
|
+
"toTime": flight_detail.get("toTime"),
|
|
493
|
+
"yprice": (flight_detail.get("fullPriceCabin") or {}).get("yPrice"),
|
|
494
|
+
}
|
|
495
|
+
if flight_detail.get("isShareFlight") and flight_detail.get("realFlightNo"):
|
|
496
|
+
segment["realFlightNo"] = flight_detail["realFlightNo"]
|
|
497
|
+
price = {
|
|
498
|
+
"passengerType": passenger_type,
|
|
499
|
+
"salePrice": cabin.get("salePrice"),
|
|
500
|
+
"departureTax": flight_detail.get("departureTax"),
|
|
501
|
+
"fuelTax": flight_detail.get("fuelTax"),
|
|
502
|
+
"rescheduledPrice": 0,
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
"segmentInfos": [{k: v for k, v in segment.items() if v is not None}],
|
|
506
|
+
"priceInfos": [{k: v for k, v in price.items() if v is not None}],
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
|
|
358
510
|
def create_order(
|
|
359
511
|
flight_detail: dict,
|
|
360
512
|
cabin: dict,
|
|
@@ -389,6 +541,10 @@ def create_order(
|
|
|
389
541
|
"estimatedTotal": estimated_total,
|
|
390
542
|
"travelBusiness": True,
|
|
391
543
|
"passengerInfo": encrypted_passenger,
|
|
544
|
+
"productInfo": build_product_info(
|
|
545
|
+
flight_detail, cabin, from_city, to_city, from_date,
|
|
546
|
+
passenger.get("passengerType") or "ADU",
|
|
547
|
+
),
|
|
392
548
|
}
|
|
393
549
|
if price_info:
|
|
394
550
|
order_data["priceInfo"] = price_info
|
|
@@ -843,9 +999,15 @@ def cmd_create_order(args: argparse.Namespace) -> int:
|
|
|
843
999
|
"message": verify_res.get("msg", ""),
|
|
844
1000
|
}
|
|
845
1001
|
|
|
1002
|
+
# 把验价后的实时价格覆盖到传入创单的对象上:productInfo 由它构造,
|
|
1003
|
+
# 否则会出现 estimatedTotal/shoppingCode 用新值、productInfo 用缓存旧值的矛盾
|
|
1004
|
+
order_flight, order_cabin = merge_verified_prices(
|
|
1005
|
+
flight, cabin, vdata, sale_price, dep_tax, fuel_tax
|
|
1006
|
+
)
|
|
1007
|
+
|
|
846
1008
|
order_res = create_order(
|
|
847
|
-
flight_detail=
|
|
848
|
-
cabin=
|
|
1009
|
+
flight_detail=order_flight,
|
|
1010
|
+
cabin=order_cabin,
|
|
849
1011
|
from_city=from_city,
|
|
850
1012
|
to_city=to_city,
|
|
851
1013
|
from_date=from_date,
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""flight_booking 创单 productInfo 的单测(不触网、不需要密钥)。
|
|
3
|
+
|
|
4
|
+
覆盖两类问题:
|
|
5
|
+
1. 验价返回 10301(票价变动)或 isUpdatePrice=true 时,productInfo 必须用**验价后的
|
|
6
|
+
实时价格与税费**,与同一请求里的 estimatedTotal / shoppingCode 保持一致;
|
|
7
|
+
2. productInfo 必须包含接口文档 6.4 规定的必填字段(SegmentInfo / RefundChangeDetail /
|
|
8
|
+
PriceInfo)。
|
|
9
|
+
|
|
10
|
+
用法::
|
|
11
|
+
|
|
12
|
+
python3 skills/flight-booking/src/scripts/test_flight_booking.py
|
|
13
|
+
|
|
14
|
+
依赖:脚本自身依赖 ``requests``(业务脚本在导入时即引入),运行前需已安装。
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# 先隔离运行环境,再导入被测模块:
|
|
24
|
+
# 模块导入时要求 SOPH_API_KEY(或 ~/.openclaw/openclaw.json)且会创建数据目录,
|
|
25
|
+
# 单测不应依赖密钥、也不该写真实用户目录。
|
|
26
|
+
_TMP_HOME = tempfile.mkdtemp(prefix="flight-booking-test-")
|
|
27
|
+
os.environ["HOME"] = _TMP_HOME
|
|
28
|
+
os.environ["USERPROFILE"] = _TMP_HOME
|
|
29
|
+
os.environ.setdefault("SOPH_API_KEY", "unit-test-key-not-used")
|
|
30
|
+
|
|
31
|
+
HERE = Path(__file__).resolve().parent
|
|
32
|
+
if str(HERE) not in sys.path:
|
|
33
|
+
sys.path.insert(0, str(HERE))
|
|
34
|
+
|
|
35
|
+
import flight_booking as fb # noqa: E402
|
|
36
|
+
|
|
37
|
+
# 接口文档 6.4 SegmentInfo 的必填字段
|
|
38
|
+
REQUIRED_SEGMENT_FIELDS = (
|
|
39
|
+
"aircraftType", "cabinClass", "cabinCode", "cabinFullPrice", "cabinType",
|
|
40
|
+
"carrierName", "departureTax", "facePrice", "flightDuration", "flightNo",
|
|
41
|
+
"fromAirportCode", "fromAirportName", "fromAirportNameEn", "fromCityCode",
|
|
42
|
+
"fromCityName", "fromDate", "fromTerminal", "fromTime", "fuelTax",
|
|
43
|
+
"productType", "refundChangeDetail", "stopCityCode",
|
|
44
|
+
"toAirportCode", "toAirportName", "toAirportNameEn", "toCityCode",
|
|
45
|
+
"toCityName", "toDate", "toTerminal", "toTime", "yprice",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# 接口文档 6.4 RefundChangeDetail 的必填字段
|
|
49
|
+
REQUIRED_REFUND_CHANGE_FIELDS = (
|
|
50
|
+
"baggageNum", "baggageKg", "baggage", "changeAmountList", "endorseRule",
|
|
51
|
+
"refundHeaders", "changeHeaders", "refundAmountList", "remark",
|
|
52
|
+
"changeRule", "refundRule",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cached_flight() -> dict:
|
|
57
|
+
"""搜索缓存里的航班对象(字段取自 Dev2 实测响应)。"""
|
|
58
|
+
return {
|
|
59
|
+
"flightNo": "MU5123",
|
|
60
|
+
"aircraft": {"code": "32N", "name": "空客A320"},
|
|
61
|
+
"carrier": {"code": "MU", "name": "东方航空"},
|
|
62
|
+
"departureTax": 50,
|
|
63
|
+
"fuelTax": 70,
|
|
64
|
+
"flightDuration": "02h15m",
|
|
65
|
+
"fromAirportCode": "SHA",
|
|
66
|
+
"fromAirportName": "虹桥国际机场",
|
|
67
|
+
"fromAirportEn": "Hongqiao International Airport",
|
|
68
|
+
"fromCityCode": "SHA",
|
|
69
|
+
"fromCityCN": "上海",
|
|
70
|
+
"fromTerminal": "T2",
|
|
71
|
+
"fromTime": "2026-10-01 19:00:00",
|
|
72
|
+
"toAirportCode": "PKX",
|
|
73
|
+
"toAirportName": "大兴国际机场",
|
|
74
|
+
"toAirportEn": "Daxing International Airport",
|
|
75
|
+
"toCityCode": "BJS",
|
|
76
|
+
"toCityCN": "北京",
|
|
77
|
+
"toTerminal": "T2",
|
|
78
|
+
"toTime": "2026-10-01 21:15:00",
|
|
79
|
+
"crossDay": False,
|
|
80
|
+
"flightStopOver": None,
|
|
81
|
+
"fullPriceCabin": {"yPrice": 3230, "cPrice": 3870, "fPrice": 8390},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cached_cabin() -> dict:
|
|
86
|
+
"""搜索缓存里的舱位对象(字段取自 Dev2 实测响应)。"""
|
|
87
|
+
return {
|
|
88
|
+
"code": "V",
|
|
89
|
+
"grade": "Y",
|
|
90
|
+
"type": "-1",
|
|
91
|
+
"salePrice": 700,
|
|
92
|
+
"facePrice": 700,
|
|
93
|
+
"productType": "STANDARD_REFUND",
|
|
94
|
+
"shoppingCode": "SC-CACHED",
|
|
95
|
+
"refundChange": {
|
|
96
|
+
"baggageNum": 1,
|
|
97
|
+
"baggageKg": 20.0,
|
|
98
|
+
"baggage": "免费托运行李额20KG",
|
|
99
|
+
"changeAmountList": [65, 194],
|
|
100
|
+
"endorseRule": "不可签转",
|
|
101
|
+
"refundHeaders": ["起飞前7天前", "起飞前2小时前"],
|
|
102
|
+
"changeHeaders": ["起飞前7天前", "起飞前2小时前"],
|
|
103
|
+
"refundAmountList": [129, 258],
|
|
104
|
+
"remark": "以航司最新规定为准",
|
|
105
|
+
"changeRule": "65-168-194-48",
|
|
106
|
+
"refundRule": "129-168-258-48",
|
|
107
|
+
# 供应商会额外返回这些字段;文档未要求,不应出现在 payload 里
|
|
108
|
+
"cabinCode": "V",
|
|
109
|
+
"cabinName": "经济舱",
|
|
110
|
+
"salePrice": 700,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _product_info(flight_detail: dict, cabin: dict, vdata: dict,
|
|
116
|
+
sale_price: float, departure_tax: float, fuel_tax: float) -> dict:
|
|
117
|
+
"""模拟 cmd_create_order 的取值路径:验价 → 覆盖价格 → 构造 productInfo。"""
|
|
118
|
+
merged_flight, merged_cabin = fb.merge_verified_prices(
|
|
119
|
+
flight_detail, cabin, vdata, sale_price, departure_tax, fuel_tax
|
|
120
|
+
)
|
|
121
|
+
return fb.build_product_info(merged_flight, merged_cabin, "SHA", "BJS", "2026-10-01")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_product_info_uses_search_prices_when_unchanged() -> None:
|
|
125
|
+
"""场景 1:验价价格不变 → productInfo 与搜索结果一致。"""
|
|
126
|
+
flight, cabin = _cached_flight(), _cached_cabin()
|
|
127
|
+
vdata = {"salePrice": 700, "departureTax": 50, "fuelTax": 70, "shoppingCode": "SC-LIVE"}
|
|
128
|
+
|
|
129
|
+
product_info = _product_info(flight, cabin, vdata, 700.0, 50.0, 70.0)
|
|
130
|
+
|
|
131
|
+
price = product_info["priceInfos"][0]
|
|
132
|
+
segment = product_info["segmentInfos"][0]
|
|
133
|
+
assert price["salePrice"] == 700, f"priceInfos.salePrice 应等于搜索结果 700,实际 {price['salePrice']}"
|
|
134
|
+
assert price["departureTax"] == 50 and price["fuelTax"] == 70, "税费应与搜索结果一致"
|
|
135
|
+
assert segment["facePrice"] == 700, "segmentInfos.facePrice 应等于搜索缓存票面价"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_product_info_follows_verified_prices_when_price_changed() -> None:
|
|
139
|
+
"""场景 2:验价返回 10301/isUpdatePrice → productInfo 必须用验价后的价格与税费。"""
|
|
140
|
+
flight, cabin = _cached_flight(), _cached_cabin()
|
|
141
|
+
# 搜索缓存:700 / 机建 50 / 燃油 70;验价后:750 / 60 / 80,票面价 800
|
|
142
|
+
vdata = {
|
|
143
|
+
"salePrice": 750, "departureTax": 60, "fuelTax": 80, "facePrice": 800,
|
|
144
|
+
"isUpdatePrice": True, "shoppingCode": "SC-LIVE",
|
|
145
|
+
}
|
|
146
|
+
sale_price, dep_tax, fuel_tax = 750.0, 60.0, 80.0
|
|
147
|
+
|
|
148
|
+
product_info = _product_info(flight, cabin, vdata, sale_price, dep_tax, fuel_tax)
|
|
149
|
+
|
|
150
|
+
price = product_info["priceInfos"][0]
|
|
151
|
+
segment = product_info["segmentInfos"][0]
|
|
152
|
+
assert price["salePrice"] == 750, f"priceInfos.salePrice 应为验价后的 750,实际 {price['salePrice']}"
|
|
153
|
+
assert price["departureTax"] == 60 and price["fuelTax"] == 80, "税费应为验价后的值"
|
|
154
|
+
assert segment["departureTax"] == 60 and segment["fuelTax"] == 80, "segmentInfos 的税费也应为验价后的值"
|
|
155
|
+
assert segment["facePrice"] == 800, "票面价应取验价响应里的 facePrice"
|
|
156
|
+
|
|
157
|
+
# 与同一请求里的 estimatedTotal 保持一致
|
|
158
|
+
estimated_total = sale_price + dep_tax + fuel_tax
|
|
159
|
+
assert price["salePrice"] + price["departureTax"] + price["fuelTax"] == estimated_total, \
|
|
160
|
+
"productInfo 的价格合计必须与 estimatedTotal 一致"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_product_info_has_all_required_doc_fields() -> None:
|
|
164
|
+
"""productInfo 必须满足文档 6.4 的必填字段(本轮 review 的问题点)。
|
|
165
|
+
|
|
166
|
+
夹具取"字段齐全"的航班(含经停城市)——经停城市是文档必填项,但只有经停航班才有值;
|
|
167
|
+
非经停航班上供应商返回 null,此时不传该字段(见 test_missing_fields_are_omitted_not_null)。
|
|
168
|
+
"""
|
|
169
|
+
flight, cabin = _cached_flight(), _cached_cabin()
|
|
170
|
+
flight["flightStopOver"] = {"cityName": "成都", "stopCityCode": "CTU", "stopDuration": "03:30"}
|
|
171
|
+
product_info = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)
|
|
172
|
+
|
|
173
|
+
assert set(product_info) == {"segmentInfos", "priceInfos"}
|
|
174
|
+
assert len(product_info["segmentInfos"]) == 1 and len(product_info["priceInfos"]) == 1
|
|
175
|
+
|
|
176
|
+
segment = product_info["segmentInfos"][0]
|
|
177
|
+
missing = [f for f in REQUIRED_SEGMENT_FIELDS if f not in segment]
|
|
178
|
+
assert not missing, f"segmentInfos 缺少文档必填字段: {missing}"
|
|
179
|
+
|
|
180
|
+
refund = segment["refundChangeDetail"]
|
|
181
|
+
missing_refund = [f for f in REQUIRED_REFUND_CHANGE_FIELDS if f not in refund]
|
|
182
|
+
assert not missing_refund, f"refundChangeDetail 缺少文档必填字段: {missing_refund}"
|
|
183
|
+
assert "cabinName" not in refund, "文档未要求的供应商字段不应带进请求"
|
|
184
|
+
|
|
185
|
+
price = product_info["priceInfos"][0]
|
|
186
|
+
missing_price = [f for f in ("passengerType", "salePrice", "departureTax", "fuelTax",
|
|
187
|
+
"rescheduledPrice") if f not in price]
|
|
188
|
+
assert not missing_price, f"priceInfos 缺少必填字段: {missing_price}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_full_price_comes_from_full_price_cabin() -> None:
|
|
192
|
+
"""cabinFullPrice / yprice 取供应商的 fullPriceCabin,而不是票面价。"""
|
|
193
|
+
flight, cabin = _cached_flight(), _cached_cabin()
|
|
194
|
+
segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
|
|
195
|
+
|
|
196
|
+
assert segment["cabinFullPrice"] == 3230, "Y 舱应取 fullPriceCabin.yPrice"
|
|
197
|
+
assert segment["yprice"] == 3230, "yprice 为经济舱全价"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_arrival_date_follows_cross_day() -> None:
|
|
201
|
+
"""到达日期:跨天航班为次日,否则与起飞同一天(文档必填)。"""
|
|
202
|
+
flight, cabin = _cached_flight(), _cached_cabin()
|
|
203
|
+
|
|
204
|
+
same_day = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
|
|
205
|
+
assert same_day["toDate"] == "2026-10-01"
|
|
206
|
+
|
|
207
|
+
flight["crossDay"] = True
|
|
208
|
+
next_day = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
|
|
209
|
+
assert next_day["toDate"] == "2026-10-02", "跨天航班到达日期应为次日"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_stop_city_code_read_from_nested_object() -> None:
|
|
213
|
+
"""经停城市要从 flightStopOver 对象里取(真实响应形如 {"cityName":…, "stopCityCode":"CTU"})。
|
|
214
|
+
|
|
215
|
+
回归护栏:曾经用 _text_or_none() 直接读该字段,遇到对象一律返回 None,
|
|
216
|
+
结果**恰恰在有经停的航班上**把文档必填的 stopCityCode 丢掉。
|
|
217
|
+
"""
|
|
218
|
+
flight, cabin = _cached_flight(), _cached_cabin()
|
|
219
|
+
|
|
220
|
+
flight["flightStopOver"] = {"cityName": "成都", "stopCityCode": "CTU", "stopDuration": "03:30"}
|
|
221
|
+
segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
|
|
222
|
+
assert segment.get("stopCityCode") == "CTU", "有经停时必须从对象里取出 stopCityCode"
|
|
223
|
+
|
|
224
|
+
# 非经停航班:字段为 null → 不传该字段
|
|
225
|
+
flight["flightStopOver"] = None
|
|
226
|
+
segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
|
|
227
|
+
assert "stopCityCode" not in segment, "非经停航班不应传空的经停城市"
|
|
228
|
+
|
|
229
|
+
# 兼容供应商直接给字符串的写法
|
|
230
|
+
flight["flightStopOver"] = "CTU"
|
|
231
|
+
segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
|
|
232
|
+
assert segment.get("stopCityCode") == "CTU", "字符串写法也应兼容"
|
|
233
|
+
|
|
234
|
+
# 对象里没有 stopCityCode → 不传
|
|
235
|
+
flight["flightStopOver"] = {"cityName": "成都"}
|
|
236
|
+
segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
|
|
237
|
+
assert "stopCityCode" not in segment
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_missing_fields_are_omitted_not_null() -> None:
|
|
241
|
+
"""源数据缺失的字段直接不传,而不是传 null。"""
|
|
242
|
+
flight, cabin = _cached_flight(), _cached_cabin()
|
|
243
|
+
flight.pop("flightDuration")
|
|
244
|
+
flight.pop("toTerminal")
|
|
245
|
+
cabin.pop("type")
|
|
246
|
+
cabin.pop("refundChange")
|
|
247
|
+
|
|
248
|
+
segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
|
|
249
|
+
|
|
250
|
+
for absent in ("flightDuration", "toTerminal", "cabinType", "refundChangeDetail", "stopCityCode"):
|
|
251
|
+
assert absent not in segment, f"{absent} 无数据时不应出现在请求里"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_merge_does_not_mutate_inputs() -> None:
|
|
255
|
+
"""覆盖价格时不能改到搜索缓存对象本身(后续逻辑仍要用原值算价差)。"""
|
|
256
|
+
flight, cabin = _cached_flight(), _cached_cabin()
|
|
257
|
+
vdata = {"salePrice": 750, "facePrice": 800, "departureTax": 60, "fuelTax": 80}
|
|
258
|
+
|
|
259
|
+
fb.merge_verified_prices(flight, cabin, vdata, 750.0, 60.0, 80.0)
|
|
260
|
+
|
|
261
|
+
assert cabin["salePrice"] == 700 and cabin["facePrice"] == 700, "原始 cabin 不应被修改"
|
|
262
|
+
assert flight["departureTax"] == 50 and flight["fuelTax"] == 70, "原始 flight 不应被修改"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
tests = [(name, fn) for name, fn in sorted(globals().items())
|
|
267
|
+
if name.startswith("test_") and callable(fn)]
|
|
268
|
+
failed = 0
|
|
269
|
+
for name, fn in tests:
|
|
270
|
+
try:
|
|
271
|
+
fn()
|
|
272
|
+
print(f"PASS {name}")
|
|
273
|
+
except AssertionError as exc:
|
|
274
|
+
failed += 1
|
|
275
|
+
print(f"FAIL {name}: {exc}")
|
|
276
|
+
print(f"\n{len(tests) - failed}/{len(tests)} passed")
|
|
277
|
+
sys.exit(1 if failed else 0)
|