sophhub 0.1.0

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.
Files changed (125) hide show
  1. package/bin/sophhub.js +21 -0
  2. package/package.json +32 -0
  3. package/skills/VERSIONS.md +27 -0
  4. package/skills/builtin/clawhub/SKILL.md +77 -0
  5. package/skills/builtin/flight-booking/SKILL.md +288 -0
  6. package/skills/builtin/flight-booking/scripts/flight_booking.py +1232 -0
  7. package/skills/builtin/inventory-management/SKILL.md +241 -0
  8. package/skills/builtin/inventory-management/scripts/inventory.py +1844 -0
  9. package/skills/builtin/schedule-reminder/SKILL.md +619 -0
  10. package/skills/builtin/schedule-reminder/schedule_template.md +68 -0
  11. package/skills/builtin/schedule-reminder/scripts/append_event.py +204 -0
  12. package/skills/builtin/schedule-reminder/scripts/create_reminders.sh +163 -0
  13. package/skills/builtin/schedule-reminder/scripts/daily_activate.sh +175 -0
  14. package/skills/builtin/schedule-reminder/scripts/parse_schedule.py +704 -0
  15. package/skills/builtin/schedule-reminder/scripts/setup.sh +242 -0
  16. package/skills/builtin/schedule-reminder//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -0
  17. package/skills/builtin/skill-creator/SKILL.md +370 -0
  18. package/skills/builtin/skill-creator/license.txt +202 -0
  19. package/skills/builtin/skill-creator/scripts/init_skill.py +378 -0
  20. package/skills/builtin/skill-creator/scripts/package_skill.py +111 -0
  21. package/skills/builtin/skill-creator/scripts/quick_validate.py +101 -0
  22. package/skills/builtin/sophnet-customer-management/SKILL.md +271 -0
  23. package/skills/builtin/sophnet-customer-management/pyproject.toml +15 -0
  24. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__init__.py +2 -0
  25. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__main__.py +5 -0
  26. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/cli.py +67 -0
  27. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/__init__.py +2 -0
  28. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/customer.py +60 -0
  29. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/export_file.py +18 -0
  30. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/import_file.py +15 -0
  31. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/reminder.py +26 -0
  32. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/schema.py +28 -0
  33. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/config.py +54 -0
  34. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/__init__.py +2 -0
  35. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/exporter.py +85 -0
  36. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/models.py +84 -0
  37. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/normalizer.py +144 -0
  38. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/parser.py +241 -0
  39. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/query.py +109 -0
  40. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/reminder.py +121 -0
  41. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/repository.py +397 -0
  42. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/schema.py +106 -0
  43. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/service.py +565 -0
  44. package/skills/builtin/sophnet-customer-management/uv.lock +48 -0
  45. package/skills/builtin/sophnet-customized-marketing/SKILL.md +144 -0
  46. package/skills/builtin/sophnet-customized-marketing/playbooks/campaign-planning.md +187 -0
  47. package/skills/builtin/sophnet-customized-marketing/playbooks/content-generation.md +124 -0
  48. package/skills/builtin/sophnet-customized-marketing/playbooks/marketing-calendar.md +59 -0
  49. package/skills/builtin/sophnet-customized-marketing/playbooks/multi-channel-bundle.md +94 -0
  50. package/skills/builtin/sophnet-customized-marketing/playbooks/poster-generation.md +182 -0
  51. package/skills/builtin/sophnet-customized-marketing/playbooks/style-profile-workflow.md +103 -0
  52. package/skills/builtin/sophnet-customized-marketing/pyproject.toml +9 -0
  53. package/skills/builtin/sophnet-customized-marketing/references/campaign-mechanics.md +168 -0
  54. package/skills/builtin/sophnet-customized-marketing/references/content-safety.md +26 -0
  55. package/skills/builtin/sophnet-customized-marketing/references/marketing-date-checklist.md +99 -0
  56. package/skills/builtin/sophnet-customized-marketing/references/platform-writing-guidelines.md +88 -0
  57. package/skills/builtin/sophnet-customized-marketing/references/quality-checklist.md +44 -0
  58. package/skills/builtin/sophnet-customized-marketing/scripts/generate_poster.py +585 -0
  59. package/skills/builtin/sophnet-customized-marketing/scripts/style_profile.py +215 -0
  60. package/skills/builtin/sophnet-face-search/SKILL.md +115 -0
  61. package/skills/builtin/sophnet-face-search/pyproject.toml +11 -0
  62. package/skills/builtin/sophnet-face-search/scripts/face_search.py +336 -0
  63. package/skills/builtin/sophnet-face-search/uv.lock +508 -0
  64. package/skills/builtin/sophnet-image-edit/SKILL.md +140 -0
  65. package/skills/builtin/sophnet-image-edit/pyproject.toml +9 -0
  66. package/skills/builtin/sophnet-image-edit/scripts/edit_and_preview.sh +68 -0
  67. package/skills/builtin/sophnet-image-edit/scripts/edit_image.py +279 -0
  68. package/skills/builtin/sophnet-image-edit/uv.lock +234 -0
  69. package/skills/builtin/sophnet-image-generate/SKILL.md +62 -0
  70. package/skills/builtin/sophnet-image-generate/pyproject.toml +9 -0
  71. package/skills/builtin/sophnet-image-generate/scripts/generate_image.py +156 -0
  72. package/skills/builtin/sophnet-image-generate/uv.lock +234 -0
  73. package/skills/builtin/sophnet-image-ocr/SKILL.md +167 -0
  74. package/skills/builtin/sophnet-image-ocr/pyproject.toml +13 -0
  75. package/skills/builtin/sophnet-image-ocr/scripts/ocr.py +226 -0
  76. package/skills/builtin/sophnet-image-ocr/uv.lock +234 -0
  77. package/skills/builtin/sophnet-infinite-talk/SKILL.md +140 -0
  78. package/skills/builtin/sophnet-infinite-talk/pyproject.toml +9 -0
  79. package/skills/builtin/sophnet-infinite-talk/scripts/gen.py +172 -0
  80. package/skills/builtin/sophnet-oss/SKILL.md +109 -0
  81. package/skills/builtin/sophnet-oss/pyproject.toml +8 -0
  82. package/skills/builtin/sophnet-oss/scripts/upload_file.py +43 -0
  83. package/skills/builtin/sophnet-qa-install/SKILL.md +210 -0
  84. package/skills/builtin/sophnet-qa-install/pyproject.toml +6 -0
  85. package/skills/builtin/sophnet-qa-install/scripts/backup_md.py +35 -0
  86. package/skills/builtin/sophnet-qa-install/scripts/check_installed.py +143 -0
  87. package/skills/builtin/sophnet-qa-install/scripts/update_config.py +142 -0
  88. package/skills/builtin/sophnet-qa-install/scripts/update_md.py +73 -0
  89. package/skills/builtin/sophnet-training-install/SKILL.md +211 -0
  90. package/skills/builtin/sophnet-training-install/pyproject.toml +6 -0
  91. package/skills/builtin/sophnet-training-install/scripts/backup_md.py +35 -0
  92. package/skills/builtin/sophnet-training-install/scripts/check_installed.py +144 -0
  93. package/skills/builtin/sophnet-training-install/scripts/update_config.py +142 -0
  94. package/skills/builtin/sophnet-training-install/scripts/update_md.py +73 -0
  95. package/skills/builtin/sophnet-tts/SKILL.md +79 -0
  96. package/skills/builtin/sophnet-tts/pyproject.toml +9 -0
  97. package/skills/builtin/sophnet-tts/scripts/gen_tts.py +130 -0
  98. package/skills/builtin/sophnet-video-generate/SKILL.md +116 -0
  99. package/skills/builtin/sophnet-video-generate/scripts/gen_video.py +304 -0
  100. package/skills/builtin/video-understand/SKILL.md +79 -0
  101. package/skills/builtin/video-understand/scripts/video_understand.py +204 -0
  102. package/skills/builtin/weather/SKILL.md +112 -0
  103. package/skills/builtin/web-scraper/SKILL.md +101 -0
  104. package/skills/builtin/web-scraper/scripts/scrape.py +270 -0
  105. package/skills/builtin/website-builder/SKILL.md +266 -0
  106. package/skills/builtin/website-builder/scripts/deploy_site.sh +46 -0
  107. package/skills/store/didi-ride/SKILL.md +309 -0
  108. package/skills/store/didi-ride/_meta.json +6 -0
  109. package/skills/store/didi-ride/assets/PREFERENCE.md +58 -0
  110. package/skills/store/didi-ride/package.json +15 -0
  111. package/skills/store/didi-ride/references/api_references.md +171 -0
  112. package/skills/store/didi-ride/references/error_handling.md +68 -0
  113. package/skills/store/didi-ride/references/setup.md +73 -0
  114. package/skills/store/didi-ride/references/workflow.md +150 -0
  115. package/skills/store/flyai/SKILL.md +119 -0
  116. package/skills/store/flyai/references/fliggy-fast-search.md +53 -0
  117. package/skills/store/flyai/references/search-flight.md +89 -0
  118. package/skills/store/flyai/references/search-hotels.md +57 -0
  119. package/skills/store/flyai/references/search-poi.md +49 -0
  120. package/src/commands/download.js +103 -0
  121. package/src/commands/list.js +67 -0
  122. package/src/utils/config.js +24 -0
  123. package/src/utils/gitlab.js +67 -0
  124. package/src/utils/paths.js +19 -0
  125. package/src/utils/versions.js +38 -0
@@ -0,0 +1,1232 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 机票查询与下单脚本:支持航班查询(前15条)、创建订单、支付校验与出票、订单状态查询、取消订单、退票。
4
+ 乘机人信息从环境变量读取,用户需提前配置。
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+
13
+ import requests
14
+
15
+ # API 配置(新版网关,Bearer API_KEY 鉴权)
16
+
17
+ GATEWAY_BASE_URL = "https://www.sophnet.com/v1/api"
18
+
19
+ TICKET_BASE = f"{GATEWAY_BASE_URL}/open-apis/ticket"
20
+ def _resolve_api_key() -> str:
21
+ key = os.environ.get("SOPH_API_KEY", "").strip()
22
+ if key:
23
+ return key
24
+ openclaw_path = os.path.expanduser("~/.openclaw/openclaw.json")
25
+ try:
26
+ with open(openclaw_path, "r", encoding="utf-8") as f:
27
+ cfg = json.load(f)
28
+ key = cfg.get("models", {}).get("providers", {}).get("sophnet", {}).get("apiKey", "")
29
+ if key:
30
+ return key
31
+ except (OSError, json.JSONDecodeError):
32
+ pass
33
+ raise RuntimeError("未找到 SOPH_API_KEY:请设置环境变量 SOPH_API_KEY ")
34
+
35
+ API_KEY = _resolve_api_key()
36
+ # 乘机人信息从环境变量读取(用户需提前设置)
37
+ ENV_PASSENGER_NAME = "PASSENGER_NAME"
38
+ ENV_PASSENGER_MOBILE = "PASSENGER_MOBILE"
39
+ ENV_PASSENGER_CREDENTIAL_NO = "PASSENGER_CREDENTIAL_NO"
40
+ ENV_PASSENGER_GENDER = "PASSENGER_GENDER"
41
+
42
+ # 最近一次航班查询结果保存路径(用于 create-order 仅传航班号时读取)
43
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
44
+ DATA_DIR = os.path.join(os.path.expanduser("~"), ".openclaw", "flight-booking")
45
+ os.makedirs(DATA_DIR, exist_ok=True)
46
+ LAST_SEARCH_FILE = os.path.join(DATA_DIR, ".last_search.json")
47
+ ORDERS_CACHE_FILE = os.path.join(DATA_DIR, ".orders_cache.json")
48
+ PASSENGERS_FILE = os.path.join(DATA_DIR, ".passengers.json")
49
+
50
+ # ---------- 基础 API 工具 ----------
51
+
52
+ def _headers() -> dict:
53
+ return {
54
+ "Content-Type": "application/json",
55
+ "Authorization": f"Bearer {API_KEY}",
56
+ }
57
+
58
+
59
+ def _extract_ota(body: dict) -> dict:
60
+ """从网关响应 {status, message, result} 中提取 OTA 层 {code, msg, data}"""
61
+ result = body.get("result")
62
+ if result is None:
63
+ return {"code": str(body.get("status", "-1")), "msg": body.get("message", "unknown")}
64
+ return result
65
+
66
+
67
+ def _post(path: str, data: dict | None = None) -> dict:
68
+ url = f"{TICKET_BASE}{path}"
69
+ resp = requests.post(url, headers=_headers(), json=data or {}, timeout=60)
70
+ return _extract_ota(resp.json())
71
+
72
+
73
+ def _get(path: str, params: dict | None = None) -> dict:
74
+ url = f"{TICKET_BASE}{path}"
75
+ resp = requests.get(url, headers=_headers(), params=params, timeout=60)
76
+ return _extract_ota(resp.json())
77
+
78
+
79
+ def encrypt_fields(fields: dict[str, str]) -> dict[str, str]:
80
+ """调用网关加密接口,将明文字段加密后返回。fields 如 {"credentialNo": "...", "mobile": "..."}"""
81
+ url = f"{TICKET_BASE}/encrypt"
82
+ resp = requests.post(url, headers=_headers(), json={"fields": fields}, timeout=30)
83
+ body = resp.json()
84
+ if body.get("status") != 0:
85
+ raise RuntimeError(f"加密接口调用失败: {body.get('message', 'unknown')}")
86
+ return body.get("result", {})
87
+
88
+
89
+ # ---------- 航班查询 ----------
90
+
91
+ # 常用城市名 -> 机场三字码(出发/到达城市解析用)
92
+ CITY_NAME_TO_CODE = {
93
+ "杭州": "HGH", "北京": "BJS", "上海": "PVG", "广州": "CAN", "深圳": "SZX",
94
+ "成都": "CTU", "西安": "XIY", "重庆": "CKG", "南京": "NKG", "武汉": "WUH",
95
+ "青岛": "TAO", "厦门": "XMN", "昆明": "KMG", "哈尔滨": "HRB", "大连": "DLC",
96
+ "沈阳": "SHE", "长沙": "CSX", "郑州": "CGO", "天津": "TSN", "海口": "HAK",
97
+ "三亚": "SYX", "乌鲁木齐": "URC", "贵阳": "KWE", "济南": "TNA", "福州": "FOC",
98
+ "南昌": "KHN", "合肥": "HFE", "石家庄": "SJW", "太原": "TYN", "长春": "CGQ",
99
+ "兰州": "LHW", "南宁": "NNG", "呼和浩特": "HET", "拉萨": "LXA", "银川": "INC",
100
+ "西宁": "XNN", "无锡": "WUX", "宁波": "NGB", "温州": "WNZ", "珠海": "ZUH",
101
+ "首都": "PEK", "大兴": "PKX", "浦东": "PVG", "虹桥": "SHA", "萧山": "HGH",
102
+ }
103
+
104
+
105
+ def resolve_city(name_or_code: str) -> str:
106
+ """将城市名或三字码转为 API 使用的三字码。若已是三字码则原样返回大写。"""
107
+ if not name_or_code or not isinstance(name_or_code, str):
108
+ return ""
109
+ s = name_or_code.strip()
110
+ if len(s) == 3 and s.isalpha():
111
+ return s.upper()
112
+ return CITY_NAME_TO_CODE.get(s) or ""
113
+
114
+
115
+ def flight_search(
116
+ from_city: str,
117
+ to_city: str,
118
+ from_date: str,
119
+ flight_no: str | None = None,
120
+ all_data: bool | None = None,
121
+ ) -> dict:
122
+ search_data = {"fromCityCode": from_city, "toCityCode": to_city, "fromDate": from_date}
123
+ if flight_no:
124
+ search_data["flightNo"] = flight_no
125
+ if all_data is not None:
126
+ search_data["allData"] = all_data
127
+ return _post("/flight/search", search_data)
128
+
129
+
130
+ def _baggage_desc(cabin: dict | None) -> str:
131
+ """从舱位的 refundChange 中提取行李额描述,优先用 baggageKg/baggageNum,其次用 baggage 文本。"""
132
+ if not cabin:
133
+ return "-"
134
+ rc = cabin.get("refundChange") or {}
135
+ kg = rc.get("baggageKg")
136
+ num = rc.get("baggageNum")
137
+ if kg is not None and kg > 0 and num is not None and num > 0:
138
+ return f"{kg}kg×{num}件"
139
+ if kg is not None and kg > 0:
140
+ return f"{kg}kg"
141
+ if num is not None and num > 0:
142
+ return f"{num}件"
143
+ text = (rc.get("baggage") or "").strip()
144
+ return text if text else "-"
145
+
146
+
147
+ def _extract_hhmm(time_str: str) -> str:
148
+ """从 'YYYY-MM-DD HH:MM:SS' 或 'HH:MM' 等格式中提取 HH:MM。"""
149
+ if not time_str:
150
+ return ""
151
+ s = time_str.strip()
152
+ if " " in s:
153
+ s = s.split(" ", 1)[1]
154
+ return s[:5] if len(s) >= 5 else s
155
+
156
+
157
+ def list_flights_simple(result: dict, top_n: int = 15) -> list[dict]:
158
+ """提取航班列表,每航班一行含经济舱/公务舱最低价、机建燃油费,按经济舱价格排序,取前 top_n 条。"""
159
+ if result.get("code") != "0":
160
+ return []
161
+ data = result.get("data", {})
162
+ flights = data.get("flightDetails", [])
163
+ out = []
164
+ for f in flights:
165
+ carrier = f.get("carrier", {})
166
+ from_airport = f.get("fromAirportName") or f.get("fromAirportCode", "")
167
+ to_airport = f.get("toAirportName") or f.get("toAirportCode", "")
168
+ from_terminal = f.get("fromTerminal") or ""
169
+ to_terminal = f.get("toTerminal") or ""
170
+ if from_terminal:
171
+ from_airport = f"{from_airport}{from_terminal}"
172
+ if to_terminal:
173
+ to_airport = f"{to_airport}{to_terminal}"
174
+ cabins = [c for c in f.get("cabins", []) if c.get("salePrice") is not None]
175
+ economy = [c for c in cabins if c.get("grade") == "Y"]
176
+ business = [c for c in cabins if c.get("grade") == "C"]
177
+ cheapest_economy = min(economy, key=lambda c: c["salePrice"]) if economy else None
178
+ cheapest_business = min(business, key=lambda c: c["salePrice"]) if business else None
179
+ out.append({
180
+ "flightNo": f.get("flightNo"),
181
+ "carrierName": carrier.get("name"),
182
+ "fromAirport": from_airport,
183
+ "toAirport": to_airport,
184
+ "fromTime": f.get("fromTime", ""),
185
+ "toTime": f.get("toTime", ""),
186
+ "economyPrice": cheapest_economy["salePrice"] if cheapest_economy else None,
187
+ "economyCabin": (cheapest_economy.get("gradeDesc") or cheapest_economy.get("code")) if cheapest_economy else None,
188
+ "businessPrice": cheapest_business["salePrice"] if cheapest_business else None,
189
+ "businessCabin": (cheapest_business.get("gradeDesc") or cheapest_business.get("code")) if cheapest_business else None,
190
+ "departureTax": f.get("departureTax", 0),
191
+ "fuelTax": f.get("fuelTax", 0),
192
+ })
193
+ out.sort(key=lambda r: (r.get("economyPrice") is None, r.get("economyPrice") or 0))
194
+ return out[:top_n]
195
+
196
+
197
+ # ---------- 表格展示(与 flight_search.py 一致) ----------
198
+
199
+ def _display_width(s: str) -> int:
200
+ """字符串在终端中的显示宽度:ASCII=1,中文等宽字符=2"""
201
+ w = 0
202
+ for c in s:
203
+ w += 2 if "\u4e00" <= c <= "\u9fff" or "\u3000" <= c <= "\u303f" or "\uff00" <= c <= "\uffef" else 1
204
+ return w
205
+
206
+
207
+ def _pad_to_width(s: str, width: int, align: str = "left") -> str:
208
+ """将字符串按显示宽度对齐到 width,不足用空格补齐"""
209
+ s = s or ""
210
+ current = _display_width(s)
211
+ if current >= width:
212
+ return s
213
+ pad = " " * (width - current)
214
+ return (s + pad) if align == "left" else (pad + s)
215
+
216
+
217
+ def _truncate_to_width(s: str, width: int) -> str:
218
+ """按显示宽度截断字符串"""
219
+ s = s or ""
220
+ w = 0
221
+ for i, c in enumerate(s):
222
+ add = 2 if "\u4e00" <= c <= "\u9fff" or "\u3000" <= c <= "\u303f" or "\uff00" <= c <= "\uffef" else 1
223
+ if w + add > width:
224
+ return s[:i]
225
+ w += add
226
+ return s
227
+
228
+
229
+ def _print_flights_table(rows: list[dict]) -> None:
230
+ """打印航班表格:起飞/到达时间分两列,含机建费、燃油费"""
231
+ W = [10, 22, 20, 20, 8, 8, 10, 10, 8, 8]
232
+ sep = " "
233
+ head = [
234
+ _pad_to_width("航班号", W[0]),
235
+ _pad_to_width("航空公司", W[1]),
236
+ _pad_to_width("出发机场", W[2]),
237
+ _pad_to_width("到达机场", W[3]),
238
+ _pad_to_width("起飞", W[4]),
239
+ _pad_to_width("到达", W[5]),
240
+ _pad_to_width("经济舱", W[6], "right"),
241
+ _pad_to_width("公务舱", W[7], "right"),
242
+ _pad_to_width("机建", W[8], "right"),
243
+ _pad_to_width("燃油", W[9], "right"),
244
+ ]
245
+ print(sep.join(head))
246
+ print("-" * (sum(W) + len(sep) * (len(W) - 1)))
247
+ for r in rows:
248
+ from_time = _extract_hhmm(r.get("fromTime") or "")
249
+ to_time = _extract_hhmm(r.get("toTime") or "")
250
+ economy_str = f"¥{r['economyPrice']}" if r.get("economyPrice") is not None else "-"
251
+ business_str = f"¥{r['businessPrice']}" if r.get("businessPrice") is not None else "-"
252
+ dep_tax = r.get("departureTax", 0)
253
+ fuel_tax = r.get("fuelTax", 0)
254
+ dep_tax_str = f"¥{dep_tax}" if dep_tax else "-"
255
+ fuel_tax_str = f"¥{fuel_tax}" if fuel_tax else "-"
256
+ line = [
257
+ _pad_to_width(_truncate_to_width(str(r.get("flightNo", "")), W[0]), W[0]),
258
+ _pad_to_width(_truncate_to_width(r.get("carrierName") or "", W[1]), W[1]),
259
+ _pad_to_width(_truncate_to_width(r.get("fromAirport") or "", W[2]), W[2]),
260
+ _pad_to_width(_truncate_to_width(r.get("toAirport") or "", W[3]), W[3]),
261
+ _pad_to_width(_truncate_to_width(from_time, W[4]), W[4]),
262
+ _pad_to_width(_truncate_to_width(to_time, W[5]), W[5]),
263
+ _pad_to_width(economy_str, W[6], "right"),
264
+ _pad_to_width(business_str, W[7], "right"),
265
+ _pad_to_width(dep_tax_str, W[8], "right"),
266
+ _pad_to_width(fuel_tax_str, W[9], "right"),
267
+ ]
268
+ print(sep.join(line))
269
+
270
+
271
+ # ---------- 舱位报价查询(实时) ----------
272
+
273
+ def cabin_search(
274
+ from_city: str,
275
+ to_city: str,
276
+ from_date: str,
277
+ flight_no: str,
278
+ air_range_type: str = "OW",
279
+ ) -> dict:
280
+ return _post("/flight/cabin-search", {
281
+ "fromCityCode": from_city,
282
+ "toCityCode": to_city,
283
+ "fromDate": from_date,
284
+ "flightNo": flight_no,
285
+ "airRangeType": air_range_type,
286
+ })
287
+
288
+
289
+ # ---------- 验舱验价 ----------
290
+
291
+ def check_price(flight_detail: dict, cabin: dict, from_city: str, to_city: str) -> dict:
292
+ detail = {
293
+ "cabinGrade": cabin.get("grade", "Y"),
294
+ "cabinCode": cabin.get("code"),
295
+ "flightNo": flight_detail.get("flightNo"),
296
+ "fromAirportCode": flight_detail.get("fromAirportCode"),
297
+ "toAirportCode": flight_detail.get("toAirportCode"),
298
+ }
299
+ if cabin.get("subCabinCode"):
300
+ detail["subCabinCode"] = cabin["subCabinCode"]
301
+ if flight_detail.get("fromTime"):
302
+ detail["fromTime"] = flight_detail["fromTime"]
303
+ if flight_detail.get("toTime"):
304
+ detail["toTime"] = flight_detail["toTime"]
305
+ if flight_detail.get("isShareFlight") and flight_detail.get("realFlightNo"):
306
+ detail["realFlightNo"] = flight_detail["realFlightNo"]
307
+ return _post("/flight/check-price", {
308
+ "airRangeType": flight_detail.get("airRangeType", "OW"),
309
+ "departureTax": flight_detail.get("departureTax", 0),
310
+ "fuelTax": flight_detail.get("fuelTax", 0),
311
+ "facePrice": cabin.get("facePrice"),
312
+ "salePrice": cabin.get("salePrice"),
313
+ "originalPrice": cabin.get("originalPrice"),
314
+ "productType": cabin.get("productType", "STANDARD_REFUND"),
315
+ "fromCityCode": from_city,
316
+ "toCityCode": to_city,
317
+ "shoppingCode": cabin.get("shoppingCode", ""),
318
+ "flightCheckPriceDetails": [detail],
319
+ })
320
+
321
+
322
+ # ---------- 从查询结果中选取航班和舱位 ----------
323
+
324
+ def pick_flight_and_cabin(
325
+ search_result: dict,
326
+ flight_no: str,
327
+ cabin_grade: str = "Y",
328
+ cheapest: bool = True,
329
+ ) -> tuple[dict, dict]:
330
+ data = search_result.get("data", {})
331
+ flights = data.get("flightDetails", [])
332
+ if not flights:
333
+ raise ValueError("查询结果中没有航班")
334
+ target_flight = None
335
+ for f in flights:
336
+ if f.get("flightNo") == flight_no:
337
+ target_flight = f
338
+ break
339
+ if not target_flight:
340
+ raise ValueError(f"未找到航班 {flight_no}")
341
+ cabins = target_flight.get("cabins", [])
342
+ matched = [c for c in cabins if c.get("grade") == cabin_grade]
343
+ if not matched:
344
+ matched = cabins
345
+ if not matched:
346
+ raise ValueError(f"该航班无可用舱位(舱位等级 {cabin_grade})")
347
+ if cheapest:
348
+ matched.sort(key=lambda c: c.get("salePrice", 999999))
349
+ return target_flight, matched[0]
350
+
351
+
352
+ # ---------- 创建订单(常规) ----------
353
+
354
+ def create_order(
355
+ flight_detail: dict,
356
+ cabin: dict,
357
+ from_city: str,
358
+ to_city: str,
359
+ from_date: str,
360
+ shopping_code: str,
361
+ price_info: str | None,
362
+ passenger: dict,
363
+ estimated_total: float,
364
+ ) -> dict:
365
+ to_encrypt: dict[str, str] = {}
366
+ if passenger.get("credentialNo"):
367
+ to_encrypt["credentialNo"] = passenger["credentialNo"]
368
+ if passenger.get("mobile"):
369
+ to_encrypt["mobile"] = passenger["mobile"]
370
+ if passenger.get("email"):
371
+ to_encrypt["email"] = passenger["email"]
372
+ encrypted = encrypt_fields(to_encrypt) if to_encrypt else {}
373
+ encrypted_passenger = dict(passenger)
374
+ for k, v in encrypted.items():
375
+ encrypted_passenger[k] = v
376
+ order_data: dict = {
377
+ "flightInfo": {
378
+ "fromCityCode": from_city,
379
+ "toCityCode": to_city,
380
+ "fromDate": from_date,
381
+ "flightNo": flight_detail.get("flightNo"),
382
+ "cabinCode": cabin.get("code"),
383
+ },
384
+ "shoppingCode": shopping_code or "",
385
+ "estimatedTotal": estimated_total,
386
+ "travelBusiness": True,
387
+ "passengerInfo": encrypted_passenger,
388
+ }
389
+ if price_info:
390
+ order_data["priceInfo"] = price_info
391
+ if cabin.get("subCabinCode"):
392
+ order_data["flightInfo"]["subCabinCode"] = cabin["subCabinCode"]
393
+ if flight_detail.get("isShareFlight") and flight_detail.get("realFlightNo"):
394
+ order_data["flightInfo"]["realFlightNo"] = flight_detail["realFlightNo"]
395
+ return _post("/order/create", order_data)
396
+
397
+
398
+ # ---------- 支付前校验 ----------
399
+
400
+ def pay_validate(order_no: str) -> dict:
401
+ return _post("/order/pay-validate", {"orderNo": order_no})
402
+
403
+
404
+ # ---------- 申请出票 ----------
405
+
406
+ def order_issue(
407
+ order_no: str,
408
+ payment_total: float = 0,
409
+ payments: list[dict] | None = None,
410
+ ) -> dict:
411
+ issue_data: dict = {"orderNo": order_no}
412
+ issue_data["paymentTotalAmount"] = payment_total
413
+ if payments:
414
+ issue_data["paymentInfos"] = payments
415
+ return _post("/order/issue", issue_data)
416
+
417
+
418
+ # ---------- 订单详情 ----------
419
+
420
+ def order_detail(order_no: str) -> dict:
421
+ return _get(f"/order/{order_no}")
422
+
423
+
424
+ def order_cancel(order_no: str) -> dict:
425
+ return _post(f"/order/{order_no}/cancel")
426
+
427
+
428
+ def order_change(
429
+ original_order_no: str,
430
+ flight_detail: dict,
431
+ cabin: dict,
432
+ from_city: str,
433
+ to_city: str,
434
+ from_date: str,
435
+ shopping_code: str,
436
+ price_info: str | None,
437
+ passenger: dict,
438
+ estimated_total: float,
439
+ ) -> dict:
440
+ to_encrypt: dict[str, str] = {}
441
+ if passenger.get("credentialNo"):
442
+ to_encrypt["credentialNo"] = passenger["credentialNo"]
443
+ if passenger.get("mobile"):
444
+ to_encrypt["mobile"] = passenger["mobile"]
445
+ if passenger.get("email"):
446
+ to_encrypt["email"] = passenger["email"]
447
+ encrypted = encrypt_fields(to_encrypt) if to_encrypt else {}
448
+ encrypted_passenger = dict(passenger)
449
+ for k, v in encrypted.items():
450
+ encrypted_passenger[k] = v
451
+ change_data: dict = {
452
+ "orderNo": original_order_no,
453
+ "changeType": 2,
454
+ "passengerInfo": encrypted_passenger,
455
+ "voluntary": True,
456
+ "flightInfo": {
457
+ "fromCityCode": from_city,
458
+ "toCityCode": to_city,
459
+ "fromDate": from_date,
460
+ "flightNo": flight_detail.get("flightNo"),
461
+ "cabinCode": cabin.get("code"),
462
+ },
463
+ "shoppingCode": shopping_code or "",
464
+ "travelBusiness": True,
465
+ "estimatedTotal": 1,
466
+ }
467
+ if price_info:
468
+ change_data["priceInfo"] = price_info
469
+ if cabin.get("subCabinCode"):
470
+ change_data["flightInfo"]["subCabinCode"] = cabin["subCabinCode"]
471
+ if flight_detail.get("isShareFlight") and flight_detail.get("realFlightNo"):
472
+ change_data["flightInfo"]["realFlightNo"] = flight_detail["realFlightNo"]
473
+ return _post("/order/change", change_data)
474
+
475
+
476
+ def order_refund(order_no: str, passengers: list[dict]) -> dict:
477
+ refund_passengers = []
478
+ for p in passengers:
479
+ encrypted = encrypt_fields({"credentialNo": p["credentialNo"]})
480
+ refund_passengers.append({
481
+ "name": p["name"],
482
+ "credentialNo": encrypted.get("credentialNo", p["credentialNo"]),
483
+ "credentialType": p.get("credentialType", "IDENTITY"),
484
+ })
485
+
486
+ return _post(f"/order/{order_no}/refund", {
487
+ "reason": "行程变更",
488
+ "voluntary": True,
489
+ "passengers": refund_passengers,
490
+ "customerRefundOrderNo": f"REF{int(time.time() * 1000)}",
491
+ "channelRefundOrderNo": f"REF{int(time.time() * 1000)}",
492
+ "callbackUrl": "",
493
+ })
494
+
495
+
496
+ # ---------- CLI ----------
497
+
498
+ def _birth_day_from_id_card(credential_no: str) -> str | None:
499
+ """从 18 位身份证号中解析出生日期,返回 YYYY-MM-DD;非 18 位或非身份证则返回 None"""
500
+ if not credential_no or len(credential_no) != 18:
501
+ return None
502
+ s = credential_no.strip()
503
+ if not s[:17].isdigit():
504
+ return None
505
+ # 第 7–14 位为出生日期 YYYYMMDD
506
+ ymd = s[6:14]
507
+ if len(ymd) != 8 or not ymd.isdigit():
508
+ return None
509
+ return f"{ymd[:4]}-{ymd[4:6]}-{ymd[6:8]}"
510
+
511
+
512
+ # 订单状态中文展示
513
+ ORDER_SHOW_STATUS_ZH = {
514
+ "WAIT_PAYMENT": "待支付",
515
+ "WAIT_ISSUE": "待出票",
516
+ "ISSUED": "已出票",
517
+ "CANCELLED": "已取消",
518
+ "REFUNDED": "已退票",
519
+ }
520
+ ORDER_STATUS_ZH = {"ORDERED": "已下单", "ISSUED": "已出票", "CANCELLED": "已取消", "REFUNDED": "已退票"}
521
+ PAYMENT_STATUS_ZH = {"WAIT_PAYMENT": "待支付", "PAID": "已支付", "REFUNDED": "已退款"}
522
+ PASSENGER_STATUS_ZH = {"NOT_ISSUE": "待出票", "ISSUED": "已出票", "REFUNDED": "已退票"}
523
+
524
+
525
+ def _print_order_summary_zh(data: dict) -> None:
526
+ """打印订单关键信息的中文摘要"""
527
+ if not data:
528
+ return
529
+ order_no = data.get("orderNo")
530
+ total = data.get("totalAmount")
531
+ show_status = data.get("orderShowStatus", "")
532
+ order_status = data.get("orderStatus", "")
533
+ pay_status = data.get("paymentStatus", "")
534
+ cabin_zh = {"Y": "经济舱", "C": "公务舱"}
535
+ print("---------- 订单关键信息 ----------")
536
+ print(f" 订单号:{order_no}")
537
+ print(f" 订单状态:{ORDER_SHOW_STATUS_ZH.get(show_status, show_status)}")
538
+ print(f" 处理状态:{ORDER_STATUS_ZH.get(order_status, order_status)}")
539
+ print(f" 支付状态:{PAYMENT_STATUS_ZH.get(pay_status, pay_status)}")
540
+ if total is not None:
541
+ print(f" 订单总额:¥{total}")
542
+ flights = data.get("orderFlights") or []
543
+ for i, f in enumerate(flights, 1):
544
+ cabin = cabin_zh.get(f.get("cabinClass"), f.get("cabinClass") or "")
545
+ from_t = f.get("fromTerminal") or ""
546
+ to_t = f.get("toTerminal") or ""
547
+ seg = f"{f.get('fromCityName','')}{f.get('fromAirportName','')}{from_t} {f.get('fromDate','')} {f.get('fromTime','')} → {f.get('toCityName','')}{f.get('toAirportName','')}{to_t} {f.get('toTime','')}"
548
+ print(f" 航程{i}:{f.get('flightNo','')} {seg} {cabin}")
549
+ rcd = f.get("refundChangeDetail") or {}
550
+ baggage = (rcd.get("baggage") or "").strip()
551
+ if baggage:
552
+ print(f" 行李额:{baggage}")
553
+ refund_headers = rcd.get("refundHeaders") or []
554
+ refund_amounts = rcd.get("refundAmountList") or []
555
+ if refund_headers and refund_amounts:
556
+ pairs = [f"{h} ¥{a}" for h, a in zip(refund_headers, refund_amounts)]
557
+ print(f" 退票手续费:{' / '.join(pairs)}")
558
+ change_headers = rcd.get("changeHeaders") or []
559
+ change_amounts = rcd.get("changeAmountList") or []
560
+ if change_headers and change_amounts:
561
+ pairs = [f"{h} ¥{a}" for h, a in zip(change_headers, change_amounts)]
562
+ print(f" 改签手续费:{' / '.join(pairs)}")
563
+ passengers = data.get("passengers") or []
564
+ for p in passengers:
565
+ st = p.get("status", "")
566
+ ticket = p.get("ticketNo") or "—"
567
+ print(f" 乘客:{p.get('name','')} 状态:{PASSENGER_STATUS_ZH.get(st, st)} 票号:{ticket}")
568
+ print("----------------------------------")
569
+
570
+
571
+ def _format_refund_change_rules(order_data: dict) -> dict:
572
+ """从订单详情中提取退改签规则,返回结构化 dict 供 Agent 展示给用户。"""
573
+ flights = order_data.get("orderFlights") or []
574
+ rules = []
575
+ for f in flights:
576
+ rcd = f.get("refundChangeDetail") or {}
577
+ segment = (
578
+ f"{f.get('fromCityName', '')} → {f.get('toCityName', '')} "
579
+ f"{f.get('flightNo', '')} {f.get('fromDate', '')} {f.get('fromTime', '')}"
580
+ )
581
+ refund_table = list(zip(
582
+ rcd.get("refundHeaders", []),
583
+ rcd.get("refundAmountList", []),
584
+ ))
585
+ change_table = list(zip(
586
+ rcd.get("changeHeaders", []),
587
+ rcd.get("changeAmountList", []),
588
+ ))
589
+ rules.append({
590
+ "segment": segment,
591
+ "facePrice": f.get("facePrice"),
592
+ "refund_rules": [{"period": h, "fee": a} for h, a in refund_table],
593
+ "change_rules": [{"period": h, "fee": a} for h, a in change_table],
594
+ "baggage": rcd.get("baggage", ""),
595
+ "endorseRule": rcd.get("endorseRule", ""),
596
+ "remark": rcd.get("remark", ""),
597
+ })
598
+ return {"refundChangeRules": rules}
599
+
600
+
601
+ # ---------- 订单缓存 ----------
602
+
603
+ def _load_orders_cache() -> dict:
604
+ """读取本地订单缓存文件,返回 {orderNo: summary} 字典。"""
605
+ if os.path.isfile(ORDERS_CACHE_FILE):
606
+ try:
607
+ with open(ORDERS_CACHE_FILE, "r", encoding="utf-8") as f:
608
+ return json.load(f)
609
+ except (OSError, json.JSONDecodeError):
610
+ pass
611
+ return {}
612
+
613
+
614
+ def _save_order_to_cache(order_no: str, order_data: dict) -> None:
615
+ """将订单详情的关键摘要追加/更新到缓存文件。"""
616
+ cache = _load_orders_cache()
617
+ cache[str(order_no)] = {
618
+ "orderNo": str(order_no),
619
+ "orderShowStatus": order_data.get("orderShowStatus"),
620
+ "orderStatus": order_data.get("orderStatus"),
621
+ "paymentStatus": order_data.get("paymentStatus"),
622
+ "totalAmount": order_data.get("totalAmount"),
623
+ "flights": [
624
+ {
625
+ "flightNo": fl.get("flightNo"),
626
+ "fromCityName": fl.get("fromCityName"),
627
+ "toCityName": fl.get("toCityName"),
628
+ "fromDate": fl.get("fromDate"),
629
+ "fromTime": fl.get("fromTime"),
630
+ }
631
+ for fl in (order_data.get("orderFlights") or [])
632
+ ],
633
+ "passengers": [
634
+ {
635
+ "name": p.get("name"),
636
+ "status": p.get("status"),
637
+ "ticketNo": p.get("ticketNo"),
638
+ }
639
+ for p in (order_data.get("passengers") or [])
640
+ ],
641
+ "refundOrders": [
642
+ {
643
+ "refundOrderNo": str(r.get("refundOrderNo", "")),
644
+ "refundOrderShowStatus": r.get("refundOrderShowStatus"),
645
+ "refundableTotalAmount": r.get("refundableTotalAmount"),
646
+ "refundTotalAmount": r.get("refundTotalAmount"),
647
+ }
648
+ for r in (order_data.get("refundOrders") or [])
649
+ ],
650
+ "changeOrders": [
651
+ {
652
+ "changeOrderNo": str(c.get("orderNo", "")),
653
+ "changeOrderShowStatus": c.get("orderShowStatus"),
654
+ }
655
+ for c in (order_data.get("changeOrders") or [])
656
+ ],
657
+ "updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
658
+ }
659
+ try:
660
+ with open(ORDERS_CACHE_FILE, "w", encoding="utf-8") as f:
661
+ json.dump(cache, f, ensure_ascii=False, indent=2)
662
+ except OSError:
663
+ pass
664
+
665
+
666
+ # ---------- 乘客身份管理 ----------
667
+
668
+ def _load_passengers() -> list[dict]:
669
+ """读取已保存的乘客身份列表。"""
670
+ if os.path.isfile(PASSENGERS_FILE):
671
+ try:
672
+ with open(PASSENGERS_FILE, "r", encoding="utf-8") as f:
673
+ return json.load(f)
674
+ except (OSError, json.JSONDecodeError):
675
+ pass
676
+ return []
677
+
678
+
679
+ def _save_passenger(name: str, mobile: str, credential_no: str, gender: str) -> dict:
680
+ """保存乘客身份到文件,以证件号去重。返回保存后的乘客记录。"""
681
+ passengers = _load_passengers()
682
+ record = {
683
+ "name": name,
684
+ "mobile": mobile,
685
+ "credentialNo": credential_no,
686
+ "gender": gender,
687
+ "savedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
688
+ }
689
+ for i, p in enumerate(passengers):
690
+ if p.get("credentialNo") == credential_no:
691
+ passengers[i] = record
692
+ break
693
+ else:
694
+ passengers.append(record)
695
+ try:
696
+ with open(PASSENGERS_FILE, "w", encoding="utf-8") as f:
697
+ json.dump(passengers, f, ensure_ascii=False, indent=2)
698
+ except OSError:
699
+ pass
700
+ return record
701
+
702
+
703
+ def cmd_search(args: argparse.Namespace) -> int:
704
+ from_code = resolve_city(args.from_city) or args.from_city.strip()
705
+ to_code = resolve_city(args.to_city) or args.to_city.strip()
706
+ if not from_code:
707
+ print(json.dumps({"ok": False, "error": f"无法识别出发城市: {args.from_city},请填写三字码或支持的城市名"}, ensure_ascii=False))
708
+ return 1
709
+ if not to_code:
710
+ print(json.dumps({"ok": False, "error": f"无法识别到达城市: {args.to_city},请填写三字码或支持的城市名"}, ensure_ascii=False))
711
+ return 1
712
+ res = flight_search(from_code, to_code, args.from_date, flight_no=getattr(args, "flight_no", None))
713
+ if res.get("code") != "0":
714
+ print(json.dumps({"ok": False, "code": res.get("code"), "msg": res.get("msg")}, ensure_ascii=False))
715
+ return 1
716
+ # 保存完整查询结果,供 create-order 仅传航班号时使用
717
+ state = {
718
+ "from_city": from_code,
719
+ "to_city": to_code,
720
+ "from_date": args.from_date,
721
+ "result": res,
722
+ }
723
+ try:
724
+ with open(LAST_SEARCH_FILE, "w", encoding="utf-8") as f:
725
+ json.dump(state, f, ensure_ascii=False, indent=2)
726
+ except OSError:
727
+ pass # 忽略写入失败,仅影响后续 create-order 需传全参
728
+ rows = list_flights_simple(res, top_n=30)
729
+ _print_flights_table(rows)
730
+ print()
731
+ print("已保存最近一次查询结果。下单请使用: create-order --flight-no <航班号> [--cabin-grade Y|C]")
732
+ return 0
733
+
734
+
735
+ def cmd_create_order(args: argparse.Namespace) -> int:
736
+ passenger_name = os.environ.get(ENV_PASSENGER_NAME, "").strip()
737
+ passenger_mobile = os.environ.get(ENV_PASSENGER_MOBILE, "").strip()
738
+ passenger_credential_no = os.environ.get(ENV_PASSENGER_CREDENTIAL_NO, "").strip()
739
+ passenger_gender = os.environ.get(ENV_PASSENGER_GENDER, "").strip().upper()
740
+
741
+ missing = []
742
+ if not passenger_name:
743
+ missing.append(ENV_PASSENGER_NAME)
744
+ if not passenger_mobile:
745
+ missing.append(ENV_PASSENGER_MOBILE)
746
+ if not passenger_credential_no:
747
+ missing.append(ENV_PASSENGER_CREDENTIAL_NO)
748
+ if passenger_gender not in ("M", "F"):
749
+ missing.append(f"{ENV_PASSENGER_GENDER} (需为 M 或 F)")
750
+ if missing:
751
+ print(json.dumps({
752
+ "ok": False,
753
+ "error": "创建订单前请设置以下环境变量",
754
+ "missing": missing,
755
+ }, ensure_ascii=False))
756
+ return 1
757
+
758
+ passenger = {
759
+ "name": passenger_name,
760
+ "gender": passenger_gender,
761
+ "credentialNo": passenger_credential_no,
762
+ "credentialType": getattr(args, "credential_type", "IDENTITY"),
763
+ "mobile": passenger_mobile,
764
+ "passengerType": getattr(args, "passenger_type", "ADU"),
765
+ }
766
+ birth_day = getattr(args, "passenger_birth_day", None)
767
+ if not birth_day and passenger_credential_no:
768
+ birth_day = _birth_day_from_id_card(passenger_credential_no)
769
+ if birth_day:
770
+ passenger["birthDay"] = birth_day
771
+
772
+ # 从最近一次查询结果读取行程与航班详情
773
+ from_city = to_city = from_date = None
774
+ search_res = None
775
+ if os.path.isfile(LAST_SEARCH_FILE):
776
+ try:
777
+ with open(LAST_SEARCH_FILE, "r", encoding="utf-8") as f:
778
+ state = json.load(f)
779
+ from_city = state.get("from_city")
780
+ to_city = state.get("to_city")
781
+ from_date = state.get("from_date")
782
+ search_res = state.get("result")
783
+ except (OSError, json.JSONDecodeError):
784
+ pass
785
+ if not search_res or not from_city or not to_city or not from_date:
786
+ print(json.dumps({
787
+ "ok": False,
788
+ "error": "未找到最近一次航班查询结果,请先执行查询航班后再下单",
789
+ }, ensure_ascii=False))
790
+ return 1
791
+ try:
792
+ flight, cabin = pick_flight_and_cabin(search_res, args.flight_no, args.cabin_grade)
793
+ except ValueError as e:
794
+ print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
795
+ return 1
796
+
797
+ verify_res = check_price(flight, cabin, from_city, to_city)
798
+ shopping_code = ""
799
+ price_info = None
800
+ if verify_res.get("code") == "0":
801
+ vdata = verify_res.get("data", {})
802
+ shopping_code = vdata.get("shoppingCode", cabin.get("shoppingCode", ""))
803
+ price_info = vdata.get("priceInfo")
804
+ else:
805
+ shopping_code = cabin.get("shoppingCode", "")
806
+
807
+ estimated_total = float(cabin.get("salePrice", 0)) + float(flight.get("departureTax", 0)) + float(flight.get("fuelTax", 0))
808
+ order_res = create_order(
809
+ flight_detail=flight,
810
+ cabin=cabin,
811
+ from_city=from_city,
812
+ to_city=to_city,
813
+ from_date=from_date,
814
+ shopping_code=shopping_code,
815
+ price_info=price_info,
816
+ passenger=passenger,
817
+ estimated_total=estimated_total,
818
+ )
819
+ if order_res.get("code") != "0":
820
+ print(json.dumps({
821
+ "ok": False,
822
+ "code": order_res.get("code"),
823
+ "msg": order_res.get("msg"),
824
+ }, ensure_ascii=False))
825
+ return 1
826
+ order_data = order_res.get("data", {})
827
+ print(json.dumps({
828
+ "ok": True,
829
+ "orderNo": str(order_data.get("orderNo", "")),
830
+ "pnrCode": order_data.get("pnrCode"),
831
+ "expireTime": order_data.get("expireTime"),
832
+ "paymentTotalAmount": estimated_total,
833
+ }, ensure_ascii=False, indent=2))
834
+ return 0
835
+
836
+
837
+ def cmd_pay_issue(args: argparse.Namespace) -> int:
838
+ order_no = args.order_no
839
+ # 从订单详情获取应付金额,无需用户传入
840
+ detail_res = order_detail(order_no=order_no)
841
+ if detail_res.get("code") != "0":
842
+ print(json.dumps({"ok": False, "step": "order_detail", "code": detail_res.get("code"), "msg": detail_res.get("msg")}, ensure_ascii=False))
843
+ return 1
844
+ data = detail_res.get("data", {})
845
+ _save_order_to_cache(order_no, data)
846
+ order_status = data.get("orderStatus", "")
847
+ if order_status == "WAIT_CHECK":
848
+ print("---------- 订单状态异常 ----------")
849
+ print(f" 订单号:{order_no}")
850
+ print(f" 当前状态:{order_status}(等待审核)")
851
+ print(" 订单审核还未通过,暂时无法出票,请等待审核完成后再操作。")
852
+ print("----------------------------------")
853
+ print(json.dumps({"ok": False, "error": "订单审核未通过(WAIT_CHECK),无法出票", "orderStatus": order_status}, ensure_ascii=False))
854
+ return 1
855
+ total = data.get("totalAmount")
856
+ if total is None:
857
+ print(json.dumps({"ok": False, "error": "订单详情中无 totalAmount,无法申请出票"}, ensure_ascii=False))
858
+ return 1
859
+ total = float(total)
860
+ pay_res = pay_validate(order_no)
861
+ if pay_res.get("code") != "0":
862
+ print(json.dumps({"ok": False, "step": "pay_validate", "code": pay_res.get("code"), "msg": pay_res.get("msg")}, ensure_ascii=False))
863
+ return 1
864
+ issue_res = order_issue(
865
+ order_no=order_no,
866
+ payment_total=total,
867
+ payments=[{
868
+ "paymentMethod": "BP_ACCOUNT",
869
+ "payAmount": total,
870
+ "paymentTradeId": f"PAY{int(time.time() * 1000)}",
871
+ }],
872
+ )
873
+ if issue_res.get("code") != "0":
874
+ print(json.dumps({
875
+ "ok": False,
876
+ "step": "order_issue",
877
+ "code": issue_res.get("code"),
878
+ "msg": issue_res.get("msg"),
879
+ }, ensure_ascii=False))
880
+ return 1
881
+ print("---------- 出票申请 ----------")
882
+ print(f" 订单号:{order_no}")
883
+ print(f" 支付金额:¥{total}")
884
+ print(" 出票申请已提交(异步处理),请等待结果通知。")
885
+ print("------------------------------")
886
+ print(json.dumps({"ok": True, "message": "出票申请已提交(异步处理)"}, ensure_ascii=False, indent=2))
887
+ return 0
888
+
889
+
890
+ def cmd_order_status(args: argparse.Namespace) -> int:
891
+ order_no = getattr(args, "order_no", None) or ""
892
+ if not order_no:
893
+ print(json.dumps({"ok": False, "error": "请提供 --order-no"}, ensure_ascii=False))
894
+ return 1
895
+ res = order_detail(order_no=order_no)
896
+ if res.get("code") != "0":
897
+ print(json.dumps({"ok": False, "code": res.get("code"), "msg": res.get("msg")}, ensure_ascii=False))
898
+ return 1
899
+ data = res.get("data", {})
900
+ _save_order_to_cache(order_no, data)
901
+ _print_order_summary_zh(data)
902
+ print(json.dumps({"ok": True, "data": data}, ensure_ascii=False, indent=2))
903
+ return 0
904
+
905
+
906
+ def cmd_cancel_order(args: argparse.Namespace) -> int:
907
+ order_no = (getattr(args, "order_no", None) or "").strip()
908
+ if not order_no:
909
+ print(json.dumps({"ok": False, "error": "请提供 --order-no"}, ensure_ascii=False))
910
+ return 1
911
+
912
+
913
+ res = order_cancel(order_no)
914
+ if res.get("code") != "0":
915
+ print(json.dumps({"ok": False, "code": res.get("code"), "msg": res.get("msg")}, ensure_ascii=False))
916
+ return 1
917
+ print(json.dumps({"ok": True, "message": f"订单 {order_no} 已取消"}, ensure_ascii=False, indent=2))
918
+ return 0
919
+
920
+
921
+ def cmd_refund_order(args: argparse.Namespace) -> int:
922
+ order_no = (getattr(args, "order_no", None) or "").strip()
923
+ if not order_no:
924
+ print(json.dumps({"ok": False, "error": "请提供 --order-no"}, ensure_ascii=False))
925
+ return 1
926
+
927
+ dry_run = getattr(args, "dry_run", False)
928
+
929
+ detail_res = order_detail(order_no=order_no)
930
+ if detail_res.get("code") != "0":
931
+ print(json.dumps({"ok": False, "step": "order_detail", "code": detail_res.get("code"), "msg": detail_res.get("msg")}, ensure_ascii=False))
932
+ return 1
933
+ data = detail_res.get("data", {})
934
+ _save_order_to_cache(order_no, data)
935
+
936
+ show_status = data.get("orderShowStatus", "")
937
+ if show_status != "ISSUED":
938
+ print(json.dumps({
939
+ "ok": False,
940
+ "error": f"订单当前状态为 {ORDER_SHOW_STATUS_ZH.get(show_status, show_status)},仅已出票(ISSUED)订单可退票",
941
+ "orderShowStatus": show_status,
942
+ }, ensure_ascii=False))
943
+ return 1
944
+
945
+ rules = _format_refund_change_rules(data)
946
+
947
+ if dry_run:
948
+ _print_order_summary_zh(data)
949
+ print(json.dumps({"ok": True, "dryRun": True, **rules}, ensure_ascii=False, indent=2))
950
+ return 0
951
+
952
+ passenger_name = os.environ.get(ENV_PASSENGER_NAME, "").strip()
953
+ passenger_credential_no = os.environ.get(ENV_PASSENGER_CREDENTIAL_NO, "").strip()
954
+ if not passenger_name or not passenger_credential_no:
955
+ print(json.dumps({
956
+ "ok": False,
957
+ "error": "退票需要乘客信息,请设置环境变量",
958
+ "missing": [k for k, v in [
959
+ (ENV_PASSENGER_NAME, passenger_name),
960
+ (ENV_PASSENGER_CREDENTIAL_NO, passenger_credential_no),
961
+ ] if not v],
962
+ }, ensure_ascii=False))
963
+ return 1
964
+
965
+ passengers = [{
966
+ "name": passenger_name,
967
+ "credentialNo": passenger_credential_no,
968
+ "credentialType": "IDENTITY",
969
+ }]
970
+
971
+ res = order_refund(order_no, passengers)
972
+ if res.get("code") != "0":
973
+ print(json.dumps({"ok": False, "code": res.get("code"), "msg": res.get("msg")}, ensure_ascii=False))
974
+ return 1
975
+ print(json.dumps({"ok": True, "message": f"订单 {order_no} 退票申请已提交", **rules}, ensure_ascii=False, indent=2))
976
+ return 0
977
+
978
+
979
+ def cmd_change_order(args: argparse.Namespace) -> int:
980
+ original_order_no = (getattr(args, "order_no", None) or "").strip()
981
+ if not original_order_no:
982
+ print(json.dumps({"ok": False, "error": "请提供 --order-no"}, ensure_ascii=False))
983
+ return 1
984
+
985
+ detail_res = order_detail(order_no=original_order_no)
986
+ if detail_res.get("code") != "0":
987
+ print(json.dumps({"ok": False, "step": "order_detail", "code": detail_res.get("code"), "msg": detail_res.get("msg")}, ensure_ascii=False))
988
+ return 1
989
+ detail_data = detail_res.get("data", {})
990
+ _save_order_to_cache(original_order_no, detail_data)
991
+
992
+ show_status = detail_data.get("orderShowStatus", "")
993
+ if show_status != "ISSUED":
994
+ print(json.dumps({
995
+ "ok": False,
996
+ "error": f"订单当前状态为 {ORDER_SHOW_STATUS_ZH.get(show_status, show_status)},仅已出票(ISSUED)订单可改签",
997
+ "orderShowStatus": show_status,
998
+ }, ensure_ascii=False))
999
+ return 1
1000
+
1001
+ rules = _format_refund_change_rules(detail_data)
1002
+
1003
+ passenger_name = os.environ.get(ENV_PASSENGER_NAME, "").strip()
1004
+ passenger_mobile = os.environ.get(ENV_PASSENGER_MOBILE, "").strip()
1005
+ passenger_credential_no = os.environ.get(ENV_PASSENGER_CREDENTIAL_NO, "").strip()
1006
+ passenger_gender = os.environ.get(ENV_PASSENGER_GENDER, "").strip().upper()
1007
+
1008
+ missing = []
1009
+ if not passenger_name:
1010
+ missing.append(ENV_PASSENGER_NAME)
1011
+ if not passenger_mobile:
1012
+ missing.append(ENV_PASSENGER_MOBILE)
1013
+ if not passenger_credential_no:
1014
+ missing.append(ENV_PASSENGER_CREDENTIAL_NO)
1015
+ if passenger_gender not in ("M", "F"):
1016
+ missing.append(f"{ENV_PASSENGER_GENDER} (需为 M 或 F)")
1017
+ if missing:
1018
+ print(json.dumps({
1019
+ "ok": False,
1020
+ "error": "改签前请设置以下环境变量",
1021
+ "missing": missing,
1022
+ }, ensure_ascii=False))
1023
+ return 1
1024
+
1025
+ passenger = {
1026
+ "name": passenger_name,
1027
+ "gender": passenger_gender,
1028
+ "credentialNo": passenger_credential_no,
1029
+ "credentialType": getattr(args, "credential_type", "IDENTITY"),
1030
+ "mobile": passenger_mobile,
1031
+ "passengerType": getattr(args, "passenger_type", "ADU"),
1032
+ }
1033
+ birth_day = getattr(args, "passenger_birth_day", None)
1034
+ if not birth_day and passenger_credential_no:
1035
+ birth_day = _birth_day_from_id_card(passenger_credential_no)
1036
+ if birth_day:
1037
+ passenger["birthDay"] = birth_day
1038
+
1039
+ from_city = to_city = from_date = None
1040
+ search_res = None
1041
+ if os.path.isfile(LAST_SEARCH_FILE):
1042
+ try:
1043
+ with open(LAST_SEARCH_FILE, "r", encoding="utf-8") as f:
1044
+ state = json.load(f)
1045
+ from_city = state.get("from_city")
1046
+ to_city = state.get("to_city")
1047
+ from_date = state.get("from_date")
1048
+ search_res = state.get("result")
1049
+ except (OSError, json.JSONDecodeError):
1050
+ pass
1051
+ if not search_res or not from_city or not to_city or not from_date:
1052
+ print(json.dumps({
1053
+ "ok": False,
1054
+ "error": "未找到最近一次航班查询结果,请先执行查询航班后再改签",
1055
+ }, ensure_ascii=False))
1056
+ return 1
1057
+
1058
+ try:
1059
+ flight, cabin = pick_flight_and_cabin(search_res, args.flight_no, args.cabin_grade)
1060
+ except ValueError as e:
1061
+ print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
1062
+ return 1
1063
+
1064
+ verify_res = check_price(flight, cabin, from_city, to_city)
1065
+ shopping_code = ""
1066
+ price_info = None
1067
+ if verify_res.get("code") == "0":
1068
+ vdata = verify_res.get("data", {})
1069
+ shopping_code = vdata.get("shoppingCode", cabin.get("shoppingCode", ""))
1070
+ price_info = vdata.get("priceInfo")
1071
+ else:
1072
+ shopping_code = cabin.get("shoppingCode", "")
1073
+
1074
+ estimated_total = float(cabin.get("salePrice", 0)) + float(flight.get("departureTax", 0)) + float(flight.get("fuelTax", 0))
1075
+ change_res = order_change(
1076
+ original_order_no=original_order_no,
1077
+ flight_detail=flight,
1078
+ cabin=cabin,
1079
+ from_city=from_city,
1080
+ to_city=to_city,
1081
+ from_date=from_date,
1082
+ shopping_code=shopping_code,
1083
+ price_info=price_info,
1084
+ passenger=passenger,
1085
+ estimated_total=estimated_total,
1086
+ )
1087
+ if change_res.get("code") != "0":
1088
+ print(json.dumps({
1089
+ "ok": False,
1090
+ "code": change_res.get("code"),
1091
+ "msg": change_res.get("msg"),
1092
+ }, ensure_ascii=False))
1093
+ return 1
1094
+ change_data = change_res.get("data", {})
1095
+ print(json.dumps({
1096
+ "ok": True,
1097
+ "changeOrderNo": str(change_data.get("orderNo", "")),
1098
+ "pnrCode": change_data.get("pnrCode"),
1099
+ "expireTime": change_data.get("expireTime"),
1100
+ "paymentTotalAmount": estimated_total,
1101
+ **rules,
1102
+ }, ensure_ascii=False, indent=2))
1103
+ return 0
1104
+
1105
+
1106
+ def cmd_list_orders(args: argparse.Namespace) -> int:
1107
+ cache = _load_orders_cache()
1108
+ if not cache:
1109
+ print(json.dumps({"ok": True, "orders": [], "message": "暂无缓存的订单记录"}, ensure_ascii=False, indent=2))
1110
+ return 0
1111
+ orders = sorted(cache.values(), key=lambda o: o.get("updatedAt", ""), reverse=True)
1112
+ print(json.dumps({"ok": True, "orders": orders}, ensure_ascii=False, indent=2))
1113
+ return 0
1114
+
1115
+
1116
+ def cmd_save_passenger(args: argparse.Namespace) -> int:
1117
+ name = os.environ.get(ENV_PASSENGER_NAME, "").strip()
1118
+ mobile = os.environ.get(ENV_PASSENGER_MOBILE, "").strip()
1119
+ credential_no = os.environ.get(ENV_PASSENGER_CREDENTIAL_NO, "").strip()
1120
+ gender = os.environ.get(ENV_PASSENGER_GENDER, "").strip().upper()
1121
+ missing = []
1122
+ if not name:
1123
+ missing.append(ENV_PASSENGER_NAME)
1124
+ if not mobile:
1125
+ missing.append(ENV_PASSENGER_MOBILE)
1126
+ if not credential_no:
1127
+ missing.append(ENV_PASSENGER_CREDENTIAL_NO)
1128
+ if gender not in ("M", "F"):
1129
+ missing.append(f"{ENV_PASSENGER_GENDER} (需为 M 或 F)")
1130
+ if missing:
1131
+ print(json.dumps({
1132
+ "ok": False,
1133
+ "error": "保存乘客身份前请设置以下环境变量",
1134
+ "missing": missing,
1135
+ }, ensure_ascii=False))
1136
+ return 1
1137
+ record = _save_passenger(name, mobile, credential_no, gender)
1138
+ print(json.dumps({"ok": True, "message": f"乘客 {name} 的身份信息已保存", "passenger": record}, ensure_ascii=False, indent=2))
1139
+ return 0
1140
+
1141
+
1142
+ def cmd_list_passengers(args: argparse.Namespace) -> int:
1143
+ passengers = _load_passengers()
1144
+ if not passengers:
1145
+ print(json.dumps({"ok": True, "passengers": [], "message": "暂无已保存的乘客身份"}, ensure_ascii=False, indent=2))
1146
+ return 0
1147
+ display = []
1148
+ for i, p in enumerate(passengers):
1149
+ cred = p.get("credentialNo", "")
1150
+ masked = cred[:6] + "****" + cred[-4:] if len(cred) >= 10 else cred
1151
+ display.append({
1152
+ "index": i + 1,
1153
+ "name": p.get("name"),
1154
+ "gender": p.get("gender"),
1155
+ "credentialNo_masked": masked,
1156
+ "mobile": p.get("mobile", "")[:3] + "****" + p.get("mobile", "")[-4:] if len(p.get("mobile", "")) >= 7 else p.get("mobile", ""),
1157
+ "savedAt": p.get("savedAt"),
1158
+ })
1159
+ print(json.dumps({"ok": True, "passengers": display, "_raw": passengers}, ensure_ascii=False, indent=2))
1160
+ return 0
1161
+
1162
+
1163
+ def main() -> int:
1164
+ parser = argparse.ArgumentParser(description="机票查询与下单")
1165
+ sub = parser.add_subparsers(dest="command", required=True)
1166
+
1167
+ # search:支持三字码或城市名(如 杭州、北京)
1168
+ p_search = sub.add_parser("search", help="航班查询,返回前15条(按经济舱价格排序)")
1169
+ p_search.add_argument("--from-city", required=True, help="出发城市:三字码(如 HGH)或城市名(如 杭州)")
1170
+ p_search.add_argument("--to-city", required=True, help="到达城市:三字码(如 BJS)或城市名(如 北京)")
1171
+ p_search.add_argument("--from-date", required=True, help="出发日期 YYYY-MM-DD")
1172
+ p_search.add_argument("--flight-no", default=None, help="可选,指定航班号只查该航班(如 CA1723)")
1173
+ p_search.set_defaults(func=cmd_search)
1174
+
1175
+ # create-order:仅需航班号,行程与舱位从最近一次 search 结果读取
1176
+ p_order = sub.add_parser("create-order", help="创建订单(需先执行 search;行程与舱位从上次查询结果读取)")
1177
+ p_order.add_argument("--flight-no", required=True, help="航班号(从 search 结果中选择)")
1178
+ p_order.add_argument("--cabin-grade", default="Y", choices=["Y", "C"], help="Y=经济舱 C=公务舱,默认 Y")
1179
+ p_order.add_argument("--passenger-birth-day", default=None, help="乘机人生日 YYYY-MM-DD(可选,默认从身份证号解析)")
1180
+ p_order.add_argument("--passenger-type", default="ADU", help="ADU/CHD/INF")
1181
+ p_order.add_argument("--credential-type", default="IDENTITY")
1182
+ p_order.set_defaults(func=cmd_create_order)
1183
+
1184
+ # pay-issue:仅需订单号,支付金额从订单详情接口自动获取
1185
+ p_issue = sub.add_parser("pay-issue", help="支付前校验并申请出票(金额从订单详情自动获取)")
1186
+ p_issue.add_argument("--order-no", required=True, help="订单号")
1187
+ p_issue.set_defaults(func=cmd_pay_issue)
1188
+
1189
+ # order-status:仅需订单号
1190
+ p_status = sub.add_parser("order-status", help="查询订单状态")
1191
+ p_status.add_argument("--order-no", required=True, help="订单号")
1192
+ p_status.set_defaults(func=cmd_order_status)
1193
+
1194
+ # cancel-order:取消订单
1195
+ p_cancel = sub.add_parser("cancel-order", help="取消订单(待支付等可取消状态)")
1196
+ p_cancel.add_argument("--order-no", required=True, help="订单号")
1197
+ p_cancel.set_defaults(func=cmd_cancel_order)
1198
+
1199
+ # refund-order:退票
1200
+ p_refund = sub.add_parser("refund-order", help="申请退票(已出票订单)")
1201
+ p_refund.add_argument("--order-no", required=True, help="订单号")
1202
+ p_refund.add_argument("--dry-run", action="store_true", default=False, help="仅查询退改签规则,不实际执行退票")
1203
+ p_refund.set_defaults(func=cmd_refund_order)
1204
+
1205
+ # change-order:改签
1206
+ p_change = sub.add_parser("change-order", help="改签(需先执行 search;从上次查询结果中选取改签航班)")
1207
+ p_change.add_argument("--order-no", required=True, help="原订单号")
1208
+ p_change.add_argument("--flight-no", required=True, help="改签目标航班号(从 search 结果中选择)")
1209
+ p_change.add_argument("--cabin-grade", default="Y", choices=["Y", "C"], help="Y=经济舱 C=公务舱,默认 Y")
1210
+ p_change.add_argument("--passenger-birth-day", default=None, help="乘机人生日 YYYY-MM-DD(可选,默认从身份证号解析)")
1211
+ p_change.add_argument("--passenger-type", default="ADU", help="ADU/CHD/INF")
1212
+ p_change.add_argument("--credential-type", default="IDENTITY")
1213
+ p_change.set_defaults(func=cmd_change_order)
1214
+
1215
+ # list-orders:列出缓存的订单
1216
+ p_list = sub.add_parser("list-orders", help="列出本地缓存的历史订单摘要")
1217
+ p_list.set_defaults(func=cmd_list_orders)
1218
+
1219
+ # save-passenger:保存当前乘客身份到文件
1220
+ p_save_pax = sub.add_parser("save-passenger", help="将当前环境变量中的乘客身份保存到本地文件")
1221
+ p_save_pax.set_defaults(func=cmd_save_passenger)
1222
+
1223
+ # list-passengers:列出已保存的乘客身份
1224
+ p_list_pax = sub.add_parser("list-passengers", help="列出已保存的所有乘客身份")
1225
+ p_list_pax.set_defaults(func=cmd_list_passengers)
1226
+
1227
+ args = parser.parse_args()
1228
+ return args.func(args)
1229
+
1230
+
1231
+ if __name__ == "__main__":
1232
+ sys.exit(main())