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.
- package/bin/sophhub.js +21 -0
- package/package.json +32 -0
- package/skills/VERSIONS.md +27 -0
- package/skills/builtin/clawhub/SKILL.md +77 -0
- package/skills/builtin/flight-booking/SKILL.md +288 -0
- package/skills/builtin/flight-booking/scripts/flight_booking.py +1232 -0
- package/skills/builtin/inventory-management/SKILL.md +241 -0
- package/skills/builtin/inventory-management/scripts/inventory.py +1844 -0
- package/skills/builtin/schedule-reminder/SKILL.md +619 -0
- package/skills/builtin/schedule-reminder/schedule_template.md +68 -0
- package/skills/builtin/schedule-reminder/scripts/append_event.py +204 -0
- package/skills/builtin/schedule-reminder/scripts/create_reminders.sh +163 -0
- package/skills/builtin/schedule-reminder/scripts/daily_activate.sh +175 -0
- package/skills/builtin/schedule-reminder/scripts/parse_schedule.py +704 -0
- package/skills/builtin/schedule-reminder/scripts/setup.sh +242 -0
- package/skills/builtin/schedule-reminder//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -0
- package/skills/builtin/skill-creator/SKILL.md +370 -0
- package/skills/builtin/skill-creator/license.txt +202 -0
- package/skills/builtin/skill-creator/scripts/init_skill.py +378 -0
- package/skills/builtin/skill-creator/scripts/package_skill.py +111 -0
- package/skills/builtin/skill-creator/scripts/quick_validate.py +101 -0
- package/skills/builtin/sophnet-customer-management/SKILL.md +271 -0
- package/skills/builtin/sophnet-customer-management/pyproject.toml +15 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__init__.py +2 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__main__.py +5 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/cli.py +67 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/__init__.py +2 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/customer.py +60 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/export_file.py +18 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/import_file.py +15 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/reminder.py +26 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/schema.py +28 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/config.py +54 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/__init__.py +2 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/exporter.py +85 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/models.py +84 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/normalizer.py +144 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/parser.py +241 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/query.py +109 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/reminder.py +121 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/repository.py +397 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/schema.py +106 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/service.py +565 -0
- package/skills/builtin/sophnet-customer-management/uv.lock +48 -0
- package/skills/builtin/sophnet-customized-marketing/SKILL.md +144 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/campaign-planning.md +187 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/content-generation.md +124 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/marketing-calendar.md +59 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/multi-channel-bundle.md +94 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/poster-generation.md +182 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/style-profile-workflow.md +103 -0
- package/skills/builtin/sophnet-customized-marketing/pyproject.toml +9 -0
- package/skills/builtin/sophnet-customized-marketing/references/campaign-mechanics.md +168 -0
- package/skills/builtin/sophnet-customized-marketing/references/content-safety.md +26 -0
- package/skills/builtin/sophnet-customized-marketing/references/marketing-date-checklist.md +99 -0
- package/skills/builtin/sophnet-customized-marketing/references/platform-writing-guidelines.md +88 -0
- package/skills/builtin/sophnet-customized-marketing/references/quality-checklist.md +44 -0
- package/skills/builtin/sophnet-customized-marketing/scripts/generate_poster.py +585 -0
- package/skills/builtin/sophnet-customized-marketing/scripts/style_profile.py +215 -0
- package/skills/builtin/sophnet-face-search/SKILL.md +115 -0
- package/skills/builtin/sophnet-face-search/pyproject.toml +11 -0
- package/skills/builtin/sophnet-face-search/scripts/face_search.py +336 -0
- package/skills/builtin/sophnet-face-search/uv.lock +508 -0
- package/skills/builtin/sophnet-image-edit/SKILL.md +140 -0
- package/skills/builtin/sophnet-image-edit/pyproject.toml +9 -0
- package/skills/builtin/sophnet-image-edit/scripts/edit_and_preview.sh +68 -0
- package/skills/builtin/sophnet-image-edit/scripts/edit_image.py +279 -0
- package/skills/builtin/sophnet-image-edit/uv.lock +234 -0
- package/skills/builtin/sophnet-image-generate/SKILL.md +62 -0
- package/skills/builtin/sophnet-image-generate/pyproject.toml +9 -0
- package/skills/builtin/sophnet-image-generate/scripts/generate_image.py +156 -0
- package/skills/builtin/sophnet-image-generate/uv.lock +234 -0
- package/skills/builtin/sophnet-image-ocr/SKILL.md +167 -0
- package/skills/builtin/sophnet-image-ocr/pyproject.toml +13 -0
- package/skills/builtin/sophnet-image-ocr/scripts/ocr.py +226 -0
- package/skills/builtin/sophnet-image-ocr/uv.lock +234 -0
- package/skills/builtin/sophnet-infinite-talk/SKILL.md +140 -0
- package/skills/builtin/sophnet-infinite-talk/pyproject.toml +9 -0
- package/skills/builtin/sophnet-infinite-talk/scripts/gen.py +172 -0
- package/skills/builtin/sophnet-oss/SKILL.md +109 -0
- package/skills/builtin/sophnet-oss/pyproject.toml +8 -0
- package/skills/builtin/sophnet-oss/scripts/upload_file.py +43 -0
- package/skills/builtin/sophnet-qa-install/SKILL.md +210 -0
- package/skills/builtin/sophnet-qa-install/pyproject.toml +6 -0
- package/skills/builtin/sophnet-qa-install/scripts/backup_md.py +35 -0
- package/skills/builtin/sophnet-qa-install/scripts/check_installed.py +143 -0
- package/skills/builtin/sophnet-qa-install/scripts/update_config.py +142 -0
- package/skills/builtin/sophnet-qa-install/scripts/update_md.py +73 -0
- package/skills/builtin/sophnet-training-install/SKILL.md +211 -0
- package/skills/builtin/sophnet-training-install/pyproject.toml +6 -0
- package/skills/builtin/sophnet-training-install/scripts/backup_md.py +35 -0
- package/skills/builtin/sophnet-training-install/scripts/check_installed.py +144 -0
- package/skills/builtin/sophnet-training-install/scripts/update_config.py +142 -0
- package/skills/builtin/sophnet-training-install/scripts/update_md.py +73 -0
- package/skills/builtin/sophnet-tts/SKILL.md +79 -0
- package/skills/builtin/sophnet-tts/pyproject.toml +9 -0
- package/skills/builtin/sophnet-tts/scripts/gen_tts.py +130 -0
- package/skills/builtin/sophnet-video-generate/SKILL.md +116 -0
- package/skills/builtin/sophnet-video-generate/scripts/gen_video.py +304 -0
- package/skills/builtin/video-understand/SKILL.md +79 -0
- package/skills/builtin/video-understand/scripts/video_understand.py +204 -0
- package/skills/builtin/weather/SKILL.md +112 -0
- package/skills/builtin/web-scraper/SKILL.md +101 -0
- package/skills/builtin/web-scraper/scripts/scrape.py +270 -0
- package/skills/builtin/website-builder/SKILL.md +266 -0
- package/skills/builtin/website-builder/scripts/deploy_site.sh +46 -0
- package/skills/store/didi-ride/SKILL.md +309 -0
- package/skills/store/didi-ride/_meta.json +6 -0
- package/skills/store/didi-ride/assets/PREFERENCE.md +58 -0
- package/skills/store/didi-ride/package.json +15 -0
- package/skills/store/didi-ride/references/api_references.md +171 -0
- package/skills/store/didi-ride/references/error_handling.md +68 -0
- package/skills/store/didi-ride/references/setup.md +73 -0
- package/skills/store/didi-ride/references/workflow.md +150 -0
- package/skills/store/flyai/SKILL.md +119 -0
- package/skills/store/flyai/references/fliggy-fast-search.md +53 -0
- package/skills/store/flyai/references/search-flight.md +89 -0
- package/skills/store/flyai/references/search-hotels.md +57 -0
- package/skills/store/flyai/references/search-poi.md +49 -0
- package/src/commands/download.js +103 -0
- package/src/commands/list.js +67 -0
- package/src/utils/config.js +24 -0
- package/src/utils/gitlab.js +67 -0
- package/src/utils/paths.js +19 -0
- 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())
|