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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sophhub",
3
- "version": "0.4.70",
3
+ "version": "0.4.71",
4
4
  "description": "SophHub CLI - Manage and download AI Agent skills and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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=flight,
848
- cabin=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)