sophhub 0.4.71 → 0.4.72
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.
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
国内酒店查询与预订脚本:关键词检索、起价列表、房型详情、预订校验、创建订单、支付、
|
|
4
|
+
订单状态查询、取消订单、部分退订。
|
|
5
|
+
|
|
6
|
+
入住人信息从环境变量读取(与 flight-booking 同名,用户需提前配置)。
|
|
7
|
+
对外契约见 llmops-backend 仓库的 doc/hotel-api.md,本脚本按该契约调用。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
# API 配置(网关,Bearer API_KEY 鉴权)。
|
|
20
|
+
# 默认线上网关;联调时用 SOPH_GATEWAY_BASE_URL 覆盖(例如指向测试环境的网关地址)。
|
|
21
|
+
GATEWAY_BASE_URL = os.environ.get(
|
|
22
|
+
"SOPH_GATEWAY_BASE_URL", "https://www.sophnet.com/api"
|
|
23
|
+
).rstrip("/")
|
|
24
|
+
HOTEL_BASE = f"{GATEWAY_BASE_URL}/open-apis/hotel"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_api_key() -> str:
|
|
28
|
+
key = os.environ.get("SOPH_API_KEY", "").strip()
|
|
29
|
+
if key:
|
|
30
|
+
return key
|
|
31
|
+
openclaw_path = os.path.expanduser("~/.openclaw/openclaw.json")
|
|
32
|
+
try:
|
|
33
|
+
with open(openclaw_path, "r", encoding="utf-8") as f:
|
|
34
|
+
cfg = json.load(f)
|
|
35
|
+
key = cfg.get("models", {}).get("providers", {}).get("sophnet", {}).get("apiKey", "")
|
|
36
|
+
if key:
|
|
37
|
+
return key
|
|
38
|
+
except (OSError, json.JSONDecodeError):
|
|
39
|
+
pass
|
|
40
|
+
raise RuntimeError("未找到 SOPH_API_KEY:请设置环境变量 SOPH_API_KEY ")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
API_KEY = _resolve_api_key()
|
|
44
|
+
|
|
45
|
+
# 入住人信息(与 flight-booking 复用同一组环境变量,用户配置一次即可)
|
|
46
|
+
ENV_GUEST_NAME = "PASSENGER_NAME"
|
|
47
|
+
ENV_GUEST_MOBILE = "PASSENGER_MOBILE"
|
|
48
|
+
ENV_GUEST_CREDENTIAL_NO = "PASSENGER_CREDENTIAL_NO"
|
|
49
|
+
|
|
50
|
+
# 本期只做国内酒店,统一走"在途"ID 体系
|
|
51
|
+
ORIGIN_TYPE_INHOUSE = 80
|
|
52
|
+
|
|
53
|
+
DATA_DIR = os.path.join(os.path.expanduser("~"), ".openclaw", "hotel-booking")
|
|
54
|
+
os.makedirs(DATA_DIR, exist_ok=True)
|
|
55
|
+
LAST_SEARCH_FILE = os.path.join(DATA_DIR, ".last_search.json")
|
|
56
|
+
LAST_DETAIL_FILE = os.path.join(DATA_DIR, ".last_detail.json")
|
|
57
|
+
ORDERS_CACHE_FILE = os.path.join(DATA_DIR, ".orders_cache.json")
|
|
58
|
+
PASSENGERS_FILE = os.path.join(DATA_DIR, ".passengers.json")
|
|
59
|
+
|
|
60
|
+
# 供应商 orderShowStatus → 中文(网关原值透传,这里只做展示翻译)
|
|
61
|
+
SHOW_STATUS_ZH = {
|
|
62
|
+
"WAIT_PAYMENT": "待支付",
|
|
63
|
+
"ISSUING": "确认中",
|
|
64
|
+
"ISSUED": "已确认",
|
|
65
|
+
"CANCELED": "已取消",
|
|
66
|
+
"REFUNDING": "退订中",
|
|
67
|
+
"REFUNDED": "已退订",
|
|
68
|
+
"PART_REFUND_CHANGE": "部分退订",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# 网关错误码 → 给用户的话术。40101 不保证幂等,写操作绝不允许自动重试。
|
|
72
|
+
ERROR_HINT = {
|
|
73
|
+
40101: "上游酒店服务异常(未拿到响应)。写操作请勿自动重试,先查订单状态再人工决定。",
|
|
74
|
+
40105: "请求参数不合法,按提示补齐字段后重试。",
|
|
75
|
+
40108: "酒店支付/退订能力暂未开放。",
|
|
76
|
+
40109: "价格已变化,需按最新报价重新确认后才能下单。",
|
|
77
|
+
40110: "该房型暂无可售库存,换房型或换日期。",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------- 基础 API 工具 ----------
|
|
82
|
+
|
|
83
|
+
def _headers() -> dict:
|
|
84
|
+
return {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
"Authorization": f"Bearer {API_KEY}",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _extract_ota(body: dict) -> dict:
|
|
91
|
+
"""从网关响应 {status, message, result} 中提取 OTA 层 {code, msg, data}"""
|
|
92
|
+
result = body.get("result")
|
|
93
|
+
if result is None:
|
|
94
|
+
return {"code": str(body.get("status", "-1")), "msg": body.get("message", "unknown")}
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _post(path: str, data: dict | None = None) -> dict:
|
|
99
|
+
resp = requests.post(f"{HOTEL_BASE}{path}", headers=_headers(), json=data or {}, timeout=60)
|
|
100
|
+
return _extract_ota(resp.json())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get(path: str) -> dict:
|
|
104
|
+
resp = requests.get(f"{HOTEL_BASE}{path}", headers=_headers(), timeout=60)
|
|
105
|
+
return _extract_ota(resp.json())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fail(status: int | str | None, message: str | None, extra: dict | None = None) -> int:
|
|
109
|
+
"""统一的失败输出:给 Agent 的 JSON,含错误码话术。"""
|
|
110
|
+
payload = {
|
|
111
|
+
"ok": False,
|
|
112
|
+
"status": status,
|
|
113
|
+
"message": message,
|
|
114
|
+
"hint": ERROR_HINT.get(status),
|
|
115
|
+
}
|
|
116
|
+
if extra:
|
|
117
|
+
payload.update(extra)
|
|
118
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------- 纯函数(可单测,不触网) ----------
|
|
123
|
+
|
|
124
|
+
def _text(value) -> str | None:
|
|
125
|
+
"""只接受非空字符串(数字会被转成字符串);其它类型视为无值。"""
|
|
126
|
+
if value is None:
|
|
127
|
+
return None
|
|
128
|
+
if isinstance(value, bool):
|
|
129
|
+
return None
|
|
130
|
+
if isinstance(value, (int, float)):
|
|
131
|
+
value = str(value)
|
|
132
|
+
if isinstance(value, str):
|
|
133
|
+
value = value.strip()
|
|
134
|
+
return value or None
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def to_number(value) -> float | None:
|
|
139
|
+
"""价格/库存字段可能是数字或字符串;解析不出来返回 None(不猜 0)。"""
|
|
140
|
+
text = _text(value)
|
|
141
|
+
if text is None:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
return float(text)
|
|
145
|
+
except ValueError:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def nights_between(check_in: str, check_out: str) -> int | None:
|
|
150
|
+
"""入住晚数;日期非法或离店不晚于入住时返回 None。"""
|
|
151
|
+
try:
|
|
152
|
+
d1 = datetime.strptime((check_in or "").strip(), "%Y-%m-%d")
|
|
153
|
+
d2 = datetime.strptime((check_out or "").strip(), "%Y-%m-%d")
|
|
154
|
+
except ValueError:
|
|
155
|
+
return None
|
|
156
|
+
nights = (d2 - d1).days
|
|
157
|
+
return nights if nights > 0 else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def plan_total(night_rates, rooms: int = 1, expected_nights: int | None = None) -> float | None:
|
|
161
|
+
"""逐晚价合计 × 房间数,与网关的比价口径一致。
|
|
162
|
+
|
|
163
|
+
逐晚价缺失、解析不出来、或条数与入住晚数不符时返回 None —— 缺一晚就少算一晚的钱,
|
|
164
|
+
绝不能按剩下的凑。
|
|
165
|
+
"""
|
|
166
|
+
if not isinstance(night_rates, list) or not night_rates:
|
|
167
|
+
return None
|
|
168
|
+
if expected_nights is not None and len(night_rates) != expected_nights:
|
|
169
|
+
return None
|
|
170
|
+
per_night = 0.0
|
|
171
|
+
for item in night_rates:
|
|
172
|
+
price = to_number((item or {}).get("price"))
|
|
173
|
+
if price is None:
|
|
174
|
+
return None
|
|
175
|
+
per_night += price
|
|
176
|
+
return round(per_night * max(int(rooms or 1), 1), 2)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def flatten_plans(detail: dict, expected_nights: int | None = None) -> list[dict]:
|
|
180
|
+
"""把详情响应的 rooms[] × ratePlans[] 摊平成"一行一个价格计划"。
|
|
181
|
+
|
|
182
|
+
必须房型 + 价格计划成对取:实测同一个 ratePlanId 会出现在不同房型下且价格不同,
|
|
183
|
+
只按 ratePlanId 索引会拿到别的房型的价。
|
|
184
|
+
"""
|
|
185
|
+
rows: list[dict] = []
|
|
186
|
+
for room in (detail or {}).get("rooms") or []:
|
|
187
|
+
if not isinstance(room, dict):
|
|
188
|
+
continue
|
|
189
|
+
room_id = _text(room.get("originRoomId")) or _text(room.get("roomId"))
|
|
190
|
+
for plan in room.get("ratePlans") or []:
|
|
191
|
+
if not isinstance(plan, dict):
|
|
192
|
+
continue
|
|
193
|
+
night_rates = plan.get("nightRates") or []
|
|
194
|
+
cancel_rule = plan.get("cancelRule") or {}
|
|
195
|
+
rows.append({
|
|
196
|
+
"originRoomId": room_id,
|
|
197
|
+
"roomName": _text(room.get("name")),
|
|
198
|
+
"bedType": _text(plan.get("bedType")) or _text(plan.get("bedInfo")),
|
|
199
|
+
"ratePlanId": _text(plan.get("ratePlanId")),
|
|
200
|
+
"price": to_number(plan.get("price")),
|
|
201
|
+
"stock": to_number(plan.get("stock")),
|
|
202
|
+
"nightTotal": plan_total(night_rates, 1, expected_nights),
|
|
203
|
+
"productType": _text(plan.get("productType")) or "UNKNOWN",
|
|
204
|
+
"breakfast": _text((night_rates[0] or {}).get("breakFastDes")) if night_rates else None,
|
|
205
|
+
"cancelRule": _text(cancel_rule.get("showDescription")),
|
|
206
|
+
"shoppingCode": _text(plan.get("shoppingCode")),
|
|
207
|
+
"nightRates": night_rates,
|
|
208
|
+
})
|
|
209
|
+
return rows
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_create_payload(
|
|
213
|
+
*,
|
|
214
|
+
city_id: str | None,
|
|
215
|
+
origin_hotel_id: str,
|
|
216
|
+
origin_room_id: str,
|
|
217
|
+
rate_plan_id: str,
|
|
218
|
+
shopping_code: str,
|
|
219
|
+
check_in: str,
|
|
220
|
+
check_out: str,
|
|
221
|
+
rooms: int,
|
|
222
|
+
total_amount: float,
|
|
223
|
+
earliest_arrival_time: str,
|
|
224
|
+
latest_arrival_time: str,
|
|
225
|
+
guests: list[dict],
|
|
226
|
+
contacter_name: str,
|
|
227
|
+
contacter_mobile: str,
|
|
228
|
+
) -> dict:
|
|
229
|
+
"""组装创单报文。无值的字段直接不下发(网关会点名字段报 40105)。"""
|
|
230
|
+
payload = {
|
|
231
|
+
"cityId": city_id,
|
|
232
|
+
"shoppingCode": shopping_code,
|
|
233
|
+
"originHotelId": origin_hotel_id,
|
|
234
|
+
"originRoomId": origin_room_id,
|
|
235
|
+
"ratePlanId": rate_plan_id,
|
|
236
|
+
"originType": ORIGIN_TYPE_INHOUSE,
|
|
237
|
+
"checkInDate": check_in,
|
|
238
|
+
"checkOutDate": check_out,
|
|
239
|
+
"numberOfRooms": rooms,
|
|
240
|
+
"totalAmount": total_amount,
|
|
241
|
+
"earliestArrivalTime": earliest_arrival_time,
|
|
242
|
+
"latestArrivalTime": latest_arrival_time,
|
|
243
|
+
"customers": guests,
|
|
244
|
+
"contacterName": contacter_name,
|
|
245
|
+
"contacterMobile": contacter_mobile,
|
|
246
|
+
}
|
|
247
|
+
return {k: v for k, v in payload.items() if v not in (None, "")}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def build_partial_refund(room_no: int, check_in: str, check_out: str, reason: str | None) -> dict:
|
|
251
|
+
"""组装部分退订报文。退订日期必须连续,且只允许同房间号同日期段。"""
|
|
252
|
+
payload = {"partialRefunds": [{
|
|
253
|
+
"roomNos": [room_no],
|
|
254
|
+
"checkInDate": check_in,
|
|
255
|
+
"checkOutDate": check_out,
|
|
256
|
+
}]}
|
|
257
|
+
if reason:
|
|
258
|
+
payload["reason"] = reason
|
|
259
|
+
return payload
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def show_status_zh(value: str | None) -> str:
|
|
263
|
+
text = _text(value)
|
|
264
|
+
if not text:
|
|
265
|
+
return "-"
|
|
266
|
+
return SHOW_STATUS_ZH.get(text.upper(), text)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------- 展示辅助 ----------
|
|
270
|
+
|
|
271
|
+
def _display_width(text: str) -> int:
|
|
272
|
+
"""中文按 2 列宽计算,保证表格在终端里对齐。"""
|
|
273
|
+
import unicodedata
|
|
274
|
+
return sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in str(text))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _pad(text, width: int, align: str = "left") -> str:
|
|
278
|
+
text = "" if text is None else str(text)
|
|
279
|
+
gap = max(width - _display_width(text), 0)
|
|
280
|
+
if align == "right":
|
|
281
|
+
return " " * gap + text
|
|
282
|
+
return text + " " * gap
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _truncate(text, width: int) -> str:
|
|
286
|
+
text = "" if text is None else str(text)
|
|
287
|
+
if _display_width(text) <= width:
|
|
288
|
+
return text
|
|
289
|
+
out = ""
|
|
290
|
+
for ch in text:
|
|
291
|
+
if _display_width(out + ch) > width - 1:
|
|
292
|
+
break
|
|
293
|
+
out += ch
|
|
294
|
+
return out + "…"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _print_table(headers: list[str], rows: list[list], aligns: list[str] | None = None) -> None:
|
|
298
|
+
aligns = aligns or ["left"] * len(headers)
|
|
299
|
+
cells = [[_truncate(c, 40) for c in row] for row in rows]
|
|
300
|
+
widths = []
|
|
301
|
+
for i, header in enumerate(headers):
|
|
302
|
+
widths.append(max([_display_width(header)] + [_display_width(r[i]) for r in cells]))
|
|
303
|
+
print(" ".join(_pad(h, widths[i]) for i, h in enumerate(headers)))
|
|
304
|
+
print(" ".join("-" * w for w in widths))
|
|
305
|
+
for row in cells:
|
|
306
|
+
print(" ".join(_pad(c, widths[i], aligns[i]) for i, c in enumerate(row)))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------- 本地缓存 ----------
|
|
310
|
+
|
|
311
|
+
def _read_json(path: str, default):
|
|
312
|
+
if os.path.isfile(path):
|
|
313
|
+
try:
|
|
314
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
315
|
+
return json.load(f)
|
|
316
|
+
except (OSError, json.JSONDecodeError):
|
|
317
|
+
pass
|
|
318
|
+
return default
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _write_json(path: str, data) -> None:
|
|
322
|
+
try:
|
|
323
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
324
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
325
|
+
except OSError:
|
|
326
|
+
pass # 缓存写失败只影响后续步骤的免参便利,不影响本次结果
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _cached_city_id(explicit, detail_cache: dict, search_cache: dict):
|
|
330
|
+
"""cityId 取值:显式入参 > 最近一次缓存(检索缓存 / 详情缓存,谁新用谁)。
|
|
331
|
+
|
|
332
|
+
下单全程必须透传 cityId,而首次 hotel-detail 时详情缓存还是空的,
|
|
333
|
+
只能从关键词检索缓存里取;如果这次检索比上次详情更新,说明用户换了城市,
|
|
334
|
+
也必须用新的(旧 cityId 会让列表/详情查不到酒店)。
|
|
335
|
+
"""
|
|
336
|
+
if explicit:
|
|
337
|
+
return explicit
|
|
338
|
+
candidates = [c for c in (detail_cache, search_cache) if (c or {}).get("cityId")]
|
|
339
|
+
if not candidates:
|
|
340
|
+
return None
|
|
341
|
+
newest = max(candidates, key=lambda c: str(c.get("createdAt") or ""))
|
|
342
|
+
return newest.get("cityId")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _select_plan(room_id: str | None, rate_plan_id: str | None):
|
|
346
|
+
"""从最近一次 hotel-detail 的缓存里选价格计划,返回 (plan, error)。"""
|
|
347
|
+
cache = _read_json(LAST_DETAIL_FILE, {}) or {}
|
|
348
|
+
plans = cache.get("plans") or []
|
|
349
|
+
if not plans:
|
|
350
|
+
return None, "没有可用的房型报价缓存,请先执行 hotel-detail"
|
|
351
|
+
matched = [
|
|
352
|
+
p for p in plans
|
|
353
|
+
if (not room_id or p.get("originRoomId") == room_id)
|
|
354
|
+
and (not rate_plan_id or p.get("ratePlanId") == rate_plan_id)
|
|
355
|
+
]
|
|
356
|
+
if not matched:
|
|
357
|
+
return None, f"缓存里没有房型 {room_id} + 价格计划 {rate_plan_id},请重新执行 hotel-detail"
|
|
358
|
+
return matched[0], None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------- 一、关键词 / 地标检索 ----------
|
|
362
|
+
|
|
363
|
+
def cmd_search_city(args: argparse.Namespace) -> int:
|
|
364
|
+
payload = {"keyword": args.keyword}
|
|
365
|
+
if args.city_id:
|
|
366
|
+
payload["cityId"] = args.city_id
|
|
367
|
+
if args.city_name:
|
|
368
|
+
payload["cityName"] = args.city_name
|
|
369
|
+
if args.page_no:
|
|
370
|
+
payload["pageNo"] = args.page_no
|
|
371
|
+
if args.page_size:
|
|
372
|
+
payload["pageSize"] = args.page_size
|
|
373
|
+
|
|
374
|
+
res = _post("/search/keyword", payload)
|
|
375
|
+
if res.get("code") != "0":
|
|
376
|
+
return _fail(res.get("code"), res.get("msg"))
|
|
377
|
+
|
|
378
|
+
data = res.get("data") or {}
|
|
379
|
+
hits = (data.get("currentCityList") or []) + (data.get("otherCityList") or [])
|
|
380
|
+
if not hits:
|
|
381
|
+
_write_json(LAST_SEARCH_FILE, {"keyword": args.keyword, "hotels": [], "cityId": None})
|
|
382
|
+
print(f"关键词「{args.keyword}」没有匹配到城市或酒店,换个说法再试。")
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
rows = []
|
|
386
|
+
hotels = []
|
|
387
|
+
cities: dict[str, str] = {}
|
|
388
|
+
for i, hit in enumerate(hits, 1):
|
|
389
|
+
city_id = _text(hit.get("cityId"))
|
|
390
|
+
city_name = _text(hit.get("cityName")) or ""
|
|
391
|
+
hotel_id = _text(hit.get("id"))
|
|
392
|
+
rows.append([i, city_name, city_id, hit.get("name"), hit.get("address")])
|
|
393
|
+
if city_id:
|
|
394
|
+
cities.setdefault(city_id, city_name)
|
|
395
|
+
if hotel_id:
|
|
396
|
+
hotels.append({
|
|
397
|
+
"originHotelId": hotel_id,
|
|
398
|
+
"name": _text(hit.get("name")),
|
|
399
|
+
"cityId": city_id,
|
|
400
|
+
"cityName": city_name,
|
|
401
|
+
"address": _text(hit.get("address")),
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
_print_table(["#", "城市", "cityId", "酒店 / 地点", "地址"], rows)
|
|
405
|
+
print()
|
|
406
|
+
if len(cities) == 1:
|
|
407
|
+
city_id, city_name = next(iter(cities.items()))
|
|
408
|
+
print(f"候选城市:{city_name} cityId={city_id}(下单全程必须透传这个 cityId)")
|
|
409
|
+
picked_city = city_id
|
|
410
|
+
else:
|
|
411
|
+
print("候选城市(多个,请让用户确认后把 cityId 传进来):")
|
|
412
|
+
for city_id, city_name in cities.items():
|
|
413
|
+
print(f" {city_name} cityId={city_id}")
|
|
414
|
+
picked_city = None
|
|
415
|
+
|
|
416
|
+
_write_json(LAST_SEARCH_FILE, {
|
|
417
|
+
"keyword": args.keyword,
|
|
418
|
+
"cityId": picked_city,
|
|
419
|
+
"cityName": cities.get(picked_city) if picked_city else None,
|
|
420
|
+
"hotels": hotels,
|
|
421
|
+
"createdAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
422
|
+
})
|
|
423
|
+
print()
|
|
424
|
+
print(f"共 {len(hits)} 条命中,已保存 {len(hotels)} 个带酒店 ID 的候选。"
|
|
425
|
+
"下一步:search-hotel --check-in <日期> --check-out <日期>")
|
|
426
|
+
return 0
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ---------- 二、起价列表 ----------
|
|
430
|
+
|
|
431
|
+
def cmd_search_hotel(args: argparse.Namespace) -> int:
|
|
432
|
+
cache = _read_json(LAST_SEARCH_FILE, {}) or {}
|
|
433
|
+
hotel_ids = [s.strip() for s in (args.hotel_ids or "").split(",") if s.strip()]
|
|
434
|
+
if not hotel_ids:
|
|
435
|
+
hotel_ids = [h.get("originHotelId") for h in (cache.get("hotels") or []) if h.get("originHotelId")]
|
|
436
|
+
|
|
437
|
+
payload = {"checkInDate": args.check_in, "checkOutDate": args.check_out,
|
|
438
|
+
"originType": ORIGIN_TYPE_INHOUSE}
|
|
439
|
+
if hotel_ids:
|
|
440
|
+
payload["originHotelIds"] = hotel_ids[:100]
|
|
441
|
+
mode = f"ID 列表模式({len(payload['originHotelIds'])} 个酒店)"
|
|
442
|
+
else:
|
|
443
|
+
city_id = args.city_id or cache.get("cityId")
|
|
444
|
+
if not city_id:
|
|
445
|
+
return _fail(None, "缺少酒店 ID 与 cityId:请先执行 search-city,或用 --hotel-ids 指定酒店 ID")
|
|
446
|
+
payload["cityId"] = city_id
|
|
447
|
+
mode = f"条件搜索模式(cityId={city_id})"
|
|
448
|
+
print("提示:条件搜索在测试环境实测返回空数组,拿不到起价属正常,改为 ID 列表模式更稳。")
|
|
449
|
+
if args.page_index:
|
|
450
|
+
payload["pageIndex"] = args.page_index
|
|
451
|
+
if args.page_size:
|
|
452
|
+
payload["pageSize"] = args.page_size
|
|
453
|
+
|
|
454
|
+
print(f"查询方式:{mode} 入住 {args.check_in} → 离店 {args.check_out}")
|
|
455
|
+
res = _post("/search/list", payload)
|
|
456
|
+
if res.get("code") != "0":
|
|
457
|
+
return _fail(res.get("code"), res.get("msg"))
|
|
458
|
+
|
|
459
|
+
items = res.get("data") or []
|
|
460
|
+
if not isinstance(items, list) or not items:
|
|
461
|
+
print("没有返回任何酒店(起价为空)。")
|
|
462
|
+
print("用途说明:该接口返回的是**起价**,用于筛酒店;房型与逐晚价请用 hotel-detail。")
|
|
463
|
+
return 0
|
|
464
|
+
|
|
465
|
+
rows = []
|
|
466
|
+
for i, item in enumerate(items, 1):
|
|
467
|
+
rows.append([i, item.get("name"), _text(item.get("originHotelId")),
|
|
468
|
+
item.get("lowestPrice"), item.get("address")])
|
|
469
|
+
_print_table(["#", "酒店", "originHotelId", "起价", "地址"], rows, aligns=["left", "left", "left", "right", "left"])
|
|
470
|
+
print()
|
|
471
|
+
print(f"共 {len(items)} 家酒店(起价非最终价)。下一步:hotel-detail --origin-hotel-id <ID> "
|
|
472
|
+
f"--check-in {args.check_in} --check-out {args.check_out}")
|
|
473
|
+
return 0
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ---------- 三、房型与价格计划详情 ----------
|
|
477
|
+
|
|
478
|
+
def cmd_hotel_detail(args: argparse.Namespace) -> int:
|
|
479
|
+
last = _read_json(LAST_DETAIL_FILE, {}) or {}
|
|
480
|
+
search = _read_json(LAST_SEARCH_FILE, {}) or {}
|
|
481
|
+
check_in = args.check_in or last.get("checkInDate")
|
|
482
|
+
check_out = args.check_out or last.get("checkOutDate")
|
|
483
|
+
if not check_in or not check_out:
|
|
484
|
+
return _fail(None, "需要 --check-in 与 --check-out(yyyy-MM-dd)")
|
|
485
|
+
nights = nights_between(check_in, check_out)
|
|
486
|
+
if not nights:
|
|
487
|
+
return _fail(None, f"入住/离店日期不合法:{check_in} → {check_out}(离店必须晚于入住)")
|
|
488
|
+
|
|
489
|
+
payload = {"originHotelId": args.origin_hotel_id, "originType": ORIGIN_TYPE_INHOUSE,
|
|
490
|
+
"checkInDate": check_in, "checkOutDate": check_out}
|
|
491
|
+
city_id = _cached_city_id(args.city_id, last, search)
|
|
492
|
+
if city_id:
|
|
493
|
+
payload["cityId"] = city_id
|
|
494
|
+
|
|
495
|
+
res = _post("/search/detail", payload)
|
|
496
|
+
if res.get("code") != "0":
|
|
497
|
+
return _fail(res.get("code"), res.get("msg"))
|
|
498
|
+
|
|
499
|
+
data = res.get("data") or {}
|
|
500
|
+
plans = flatten_plans(data, expected_nights=nights)
|
|
501
|
+
if not plans:
|
|
502
|
+
return _fail(None, "详情里没有可售房型/价格计划")
|
|
503
|
+
|
|
504
|
+
rows = []
|
|
505
|
+
for i, plan in enumerate(plans, 1):
|
|
506
|
+
rows.append([
|
|
507
|
+
i, plan["roomName"], plan["bedType"], plan["originRoomId"], plan["ratePlanId"],
|
|
508
|
+
plan["price"], plan["nightTotal"], plan["stock"],
|
|
509
|
+
plan["breakfast"] or "-", plan["cancelRule"] or "-", plan["productType"],
|
|
510
|
+
])
|
|
511
|
+
print(f"{data.get('name') or ''} {check_in} → {check_out}({nights} 晚) 起价 {data.get('lowestPrice')}")
|
|
512
|
+
print()
|
|
513
|
+
_print_table(
|
|
514
|
+
["#", "房型", "床型", "originRoomId", "ratePlanId", "每晚", f"{nights}晚合计", "库存", "早餐", "取消规则", "价格类型"],
|
|
515
|
+
rows,
|
|
516
|
+
aligns=["left", "left", "left", "left", "left", "right", "right", "right", "left", "left", "left"],
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
_write_json(LAST_DETAIL_FILE, {
|
|
520
|
+
"originHotelId": _text(data.get("originHotelId")) or args.origin_hotel_id,
|
|
521
|
+
"hotelName": data.get("name"),
|
|
522
|
+
"cityId": city_id,
|
|
523
|
+
"checkInDate": check_in,
|
|
524
|
+
"checkOutDate": check_out,
|
|
525
|
+
"nights": nights,
|
|
526
|
+
"plans": plans,
|
|
527
|
+
"createdAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
528
|
+
})
|
|
529
|
+
print()
|
|
530
|
+
print("已保存本次报价。下单前请把**最新总价**展示给用户确认,然后执行:")
|
|
531
|
+
print(" create-order --room-id <originRoomId> --rate-plan-id <ratePlanId> [--rooms N]")
|
|
532
|
+
return 0
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ---------- 四、预订校验 ----------
|
|
536
|
+
|
|
537
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
538
|
+
plan, err = _select_plan(args.room_id, args.rate_plan_id)
|
|
539
|
+
if err:
|
|
540
|
+
return _fail(None, err)
|
|
541
|
+
cache = _read_json(LAST_DETAIL_FILE, {}) or {}
|
|
542
|
+
payload = {
|
|
543
|
+
"shoppingCode": plan.get("shoppingCode"),
|
|
544
|
+
"customerNum": args.customer_num,
|
|
545
|
+
"checkInDate": cache.get("checkInDate"),
|
|
546
|
+
"checkOutDate": cache.get("checkOutDate"),
|
|
547
|
+
"numberOfRooms": args.rooms,
|
|
548
|
+
"totalAmount": plan.get("nightTotal"),
|
|
549
|
+
"originHotelId": cache.get("originHotelId"),
|
|
550
|
+
"originRoomId": plan.get("originRoomId"),
|
|
551
|
+
"ratePlanId": plan.get("ratePlanId"),
|
|
552
|
+
"originType": ORIGIN_TYPE_INHOUSE,
|
|
553
|
+
"earliestArrivalTime": f"{cache.get('checkInDate')} 14:00:00",
|
|
554
|
+
"latestArrivalTime": f"{cache.get('checkInDate')} 23:59:00",
|
|
555
|
+
}
|
|
556
|
+
res = _post("/search/validate", payload)
|
|
557
|
+
if res.get("code") != "0":
|
|
558
|
+
return _fail(res.get("code"), res.get("msg"))
|
|
559
|
+
print(json.dumps({"ok": True, "message": "报价码仍有效(价格与库存以 hotel-detail 为准)",
|
|
560
|
+
"data": res.get("data")}, ensure_ascii=False))
|
|
561
|
+
return 0
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# ---------- 五、创建订单 ----------
|
|
565
|
+
|
|
566
|
+
def _guests_from_env(rooms: int, credential_type: str):
|
|
567
|
+
name = os.environ.get(ENV_GUEST_NAME, "").strip()
|
|
568
|
+
mobile = os.environ.get(ENV_GUEST_MOBILE, "").strip()
|
|
569
|
+
credential_no = os.environ.get(ENV_GUEST_CREDENTIAL_NO, "").strip()
|
|
570
|
+
missing = []
|
|
571
|
+
if not name:
|
|
572
|
+
missing.append(ENV_GUEST_NAME)
|
|
573
|
+
if not mobile:
|
|
574
|
+
missing.append(ENV_GUEST_MOBILE)
|
|
575
|
+
if not credential_no:
|
|
576
|
+
missing.append(ENV_GUEST_CREDENTIAL_NO)
|
|
577
|
+
if missing:
|
|
578
|
+
return None, missing, name, mobile
|
|
579
|
+
guests = [{
|
|
580
|
+
"name": name,
|
|
581
|
+
"mobile": mobile,
|
|
582
|
+
"credentialType": credential_type,
|
|
583
|
+
"credentialNo": credential_no,
|
|
584
|
+
"roomNo": room_no,
|
|
585
|
+
} for room_no in range(1, rooms + 1)]
|
|
586
|
+
return guests, [], name, mobile
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _expire_time_text(expire_time) -> str:
|
|
590
|
+
"""上游 expireTime 是毫秒时间戳,转成本地可读时间。"""
|
|
591
|
+
try:
|
|
592
|
+
return datetime.fromtimestamp(int(expire_time) / 1000).strftime("%Y-%m-%d %H:%M:%S")
|
|
593
|
+
except (TypeError, ValueError, OSError, OverflowError):
|
|
594
|
+
return str(expire_time)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def cmd_create_order(args: argparse.Namespace) -> int:
|
|
598
|
+
plan, err = _select_plan(args.room_id, args.rate_plan_id)
|
|
599
|
+
if err:
|
|
600
|
+
return _fail(None, err)
|
|
601
|
+
cache = _read_json(LAST_DETAIL_FILE, {}) or {}
|
|
602
|
+
check_in = cache.get("checkInDate")
|
|
603
|
+
check_out = cache.get("checkOutDate")
|
|
604
|
+
nights = cache.get("nights") or nights_between(check_in, check_out)
|
|
605
|
+
if not (check_in and check_out and nights):
|
|
606
|
+
return _fail(None, "缺少入住/离店日期缓存,请重新执行 hotel-detail")
|
|
607
|
+
|
|
608
|
+
rooms = max(int(args.rooms or 1), 1)
|
|
609
|
+
total = plan_total(plan.get("nightRates"), rooms, nights)
|
|
610
|
+
if total is None:
|
|
611
|
+
return _fail(None, "缓存的逐晚价不完整或晚数对不上,请重新执行 hotel-detail 后再下单")
|
|
612
|
+
|
|
613
|
+
guests, missing, guest_name, guest_mobile = _guests_from_env(rooms, args.credential_type)
|
|
614
|
+
if missing:
|
|
615
|
+
return _fail(None, "创建订单前请设置以下环境变量", {"missing": missing})
|
|
616
|
+
|
|
617
|
+
payload = build_create_payload(
|
|
618
|
+
city_id=cache.get("cityId"),
|
|
619
|
+
origin_hotel_id=cache.get("originHotelId"),
|
|
620
|
+
origin_room_id=plan.get("originRoomId"),
|
|
621
|
+
rate_plan_id=plan.get("ratePlanId"),
|
|
622
|
+
shopping_code=plan.get("shoppingCode"),
|
|
623
|
+
check_in=check_in,
|
|
624
|
+
check_out=check_out,
|
|
625
|
+
rooms=rooms,
|
|
626
|
+
total_amount=total,
|
|
627
|
+
earliest_arrival_time=args.earliest_arrival_time or f"{check_in} 14:00:00",
|
|
628
|
+
latest_arrival_time=args.latest_arrival_time or f"{check_in} 23:59:00",
|
|
629
|
+
guests=guests,
|
|
630
|
+
contacter_name=args.contacter_name or guest_name,
|
|
631
|
+
contacter_mobile=args.contacter_mobile or guest_mobile,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
print("即将提交的订单:")
|
|
635
|
+
print(f" 酒店:{cache.get('hotelName')}(originHotelId={cache.get('originHotelId')})")
|
|
636
|
+
print(f" 房型:{plan.get('roomName')} / {plan.get('bedType')}(originRoomId={plan.get('originRoomId')})")
|
|
637
|
+
print(f" 入住:{check_in} → {check_out}({nights} 晚 × {rooms} 间)")
|
|
638
|
+
print(f" 价格计划:{plan.get('ratePlanId')} 取消规则:{plan.get('cancelRule') or '-'}")
|
|
639
|
+
print(f" 应付金额:¥{total}")
|
|
640
|
+
print(f" 入住人:{guest_name} 联系人:{payload.get('contacterName')}")
|
|
641
|
+
print()
|
|
642
|
+
|
|
643
|
+
res = _post("/order/create", payload)
|
|
644
|
+
if res.get("code") != "0":
|
|
645
|
+
return _fail(res.get("code"), res.get("msg"))
|
|
646
|
+
|
|
647
|
+
data = res.get("data") or {}
|
|
648
|
+
order_no = _text(data.get("orderNo"))
|
|
649
|
+
if not order_no:
|
|
650
|
+
return _fail(None, f"创单响应里没有订单号:{json.dumps(data, ensure_ascii=False)}")
|
|
651
|
+
|
|
652
|
+
print(f"创单成功 订单号:{order_no}")
|
|
653
|
+
print(f" 应付金额:¥{total}")
|
|
654
|
+
print(f" 超时自动取消时间:{_expire_time_text(data.get('expireTime'))}")
|
|
655
|
+
|
|
656
|
+
cache_orders = _read_json(ORDERS_CACHE_FILE, {}) or {}
|
|
657
|
+
cache_orders[order_no] = {
|
|
658
|
+
"orderNo": order_no,
|
|
659
|
+
"showStatus": "WAIT_PAYMENT",
|
|
660
|
+
"totalAmount": total,
|
|
661
|
+
"hotelName": cache.get("hotelName"),
|
|
662
|
+
"roomName": plan.get("roomName"),
|
|
663
|
+
"checkInDate": check_in,
|
|
664
|
+
"checkOutDate": check_out,
|
|
665
|
+
"rooms": rooms,
|
|
666
|
+
"expireTime": _expire_time_text(data.get("expireTime")),
|
|
667
|
+
"updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
668
|
+
}
|
|
669
|
+
_write_json(ORDERS_CACHE_FILE, cache_orders)
|
|
670
|
+
|
|
671
|
+
print()
|
|
672
|
+
print("重要:请先把订单号、入住信息与金额展示给用户确认,用户明确表示要支付后再执行:")
|
|
673
|
+
print(f" pay-order --order-no {order_no}")
|
|
674
|
+
return 0
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# ---------- 六、支付 ----------
|
|
678
|
+
|
|
679
|
+
def cmd_pay_order(args: argparse.Namespace) -> int:
|
|
680
|
+
detail = _get(f"/order/{args.order_no}")
|
|
681
|
+
if detail.get("code") != "0":
|
|
682
|
+
return _fail(detail.get("code"), detail.get("msg"))
|
|
683
|
+
data = detail.get("data") or {}
|
|
684
|
+
amount = args.pay_amount if args.pay_amount is not None else to_number(data.get("totalAmount"))
|
|
685
|
+
if amount is None:
|
|
686
|
+
return _fail(None, "拿不到应付金额,请用 --pay-amount 指定")
|
|
687
|
+
|
|
688
|
+
payload = {
|
|
689
|
+
"orderNo": args.order_no,
|
|
690
|
+
"paymentMethod": "BP_ACCOUNT",
|
|
691
|
+
"payAmount": amount,
|
|
692
|
+
"paymentTotalPrice": amount,
|
|
693
|
+
"paymentTradeId": f"HB-{int(time.time() * 1000)}",
|
|
694
|
+
}
|
|
695
|
+
print(f"订单 {args.order_no} 应付 ¥{amount} 支付方式:企业账户(BP_ACCOUNT)")
|
|
696
|
+
res = _post("/order/pay", payload)
|
|
697
|
+
if res.get("code") != "0":
|
|
698
|
+
return _fail(res.get("code"), res.get("msg"))
|
|
699
|
+
|
|
700
|
+
print(f"支付已受理 {json.dumps(res.get('data'), ensure_ascii=False)}")
|
|
701
|
+
print("注意:code=0 只代表**受理**,不代表已确认。请用 order-status 轮询确认终态:")
|
|
702
|
+
print(f" order-status --order-no {args.order_no}")
|
|
703
|
+
return 0
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# ---------- 七、订单状态 ----------
|
|
707
|
+
|
|
708
|
+
def _print_order_summary(order_no: str, data: dict) -> None:
|
|
709
|
+
print(f"订单号:{order_no}")
|
|
710
|
+
print(f" 订单状态:{show_status_zh(data.get('showStatus'))}({data.get('showStatus') or '-'})")
|
|
711
|
+
print(f" 本地状态:{data.get('status') or '-'}")
|
|
712
|
+
print(f" 酒店 / 房型:{data.get('hotelName') or '-'} / {data.get('roomName') or '-'}")
|
|
713
|
+
print(f" 入住:{data.get('checkInDate') or '-'} → {data.get('checkOutDate') or '-'}"
|
|
714
|
+
f"({data.get('nightCount') or '-'} 晚 × {data.get('roomCount') or '-'} 间)")
|
|
715
|
+
print(f" 订单总额:¥{data.get('totalAmount')} 违约金:¥{data.get('penaltyAmount')}")
|
|
716
|
+
print(f" 确认号:{data.get('confirmNo') or '-'}")
|
|
717
|
+
if data.get("cancelReason"):
|
|
718
|
+
print(f" 取消原因:{data.get('cancelReason')}")
|
|
719
|
+
if data.get("failReason"):
|
|
720
|
+
print(f" 失败原因:{data.get('failReason')}")
|
|
721
|
+
print(f" 超时自动取消时间:{_expire_time_text(data.get('expireTime')) if data.get('expireTime') else '-'}")
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def cmd_order_status(args: argparse.Namespace) -> int:
|
|
725
|
+
res = _get(f"/order/{args.order_no}")
|
|
726
|
+
if res.get("code") != "0":
|
|
727
|
+
return _fail(res.get("code"), res.get("msg"))
|
|
728
|
+
data = res.get("data") or {}
|
|
729
|
+
if not data:
|
|
730
|
+
print(json.dumps({"ok": False, "message": f"本地没有订单 {args.order_no}"}, ensure_ascii=False))
|
|
731
|
+
return 1
|
|
732
|
+
_print_order_summary(args.order_no, data)
|
|
733
|
+
|
|
734
|
+
cache_orders = _read_json(ORDERS_CACHE_FILE, {}) or {}
|
|
735
|
+
record = cache_orders.get(args.order_no) or {"orderNo": args.order_no}
|
|
736
|
+
record.update({
|
|
737
|
+
"showStatus": data.get("showStatus"),
|
|
738
|
+
"status": data.get("status"),
|
|
739
|
+
"totalAmount": data.get("totalAmount"),
|
|
740
|
+
"confirmNo": data.get("confirmNo"),
|
|
741
|
+
"updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
742
|
+
})
|
|
743
|
+
cache_orders[args.order_no] = record
|
|
744
|
+
_write_json(ORDERS_CACHE_FILE, cache_orders)
|
|
745
|
+
return 0
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# ---------- 八、取消订单 ----------
|
|
749
|
+
|
|
750
|
+
def cmd_cancel_order(args: argparse.Namespace) -> int:
|
|
751
|
+
res = _post(f"/order/{args.order_no}/cancel")
|
|
752
|
+
if res.get("code") != "0":
|
|
753
|
+
return _fail(res.get("code"), res.get("msg"))
|
|
754
|
+
print(f"已提交取消 {json.dumps(res.get('data'), ensure_ascii=False)}")
|
|
755
|
+
print("未支付订单取消后即释放冻结额度;已支付订单请用 order-status 确认最终状态。")
|
|
756
|
+
return 0
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# ---------- 九、部分退订 ----------
|
|
760
|
+
|
|
761
|
+
def first_refund_order_no(data) -> str | None:
|
|
762
|
+
"""从退订响应里取退单号。
|
|
763
|
+
|
|
764
|
+
整单退订 `data` 是对象、部分退订 `data` 是**数组**(dev8 实测),两种形态都要认;
|
|
765
|
+
取不到就返回 None(调用方据此提示用户,绝不能当成整单取消)。
|
|
766
|
+
注意字符串 `"null"`(供应商在"未确认订单等价取消"时会这么回)也要当成没有退单号。
|
|
767
|
+
"""
|
|
768
|
+
def _normalize(value) -> str | None:
|
|
769
|
+
text = _text(value)
|
|
770
|
+
if text is None or text.lower() == "null":
|
|
771
|
+
return None
|
|
772
|
+
return text
|
|
773
|
+
|
|
774
|
+
if isinstance(data, dict):
|
|
775
|
+
return _normalize(data.get("refundOrderNo"))
|
|
776
|
+
if isinstance(data, list):
|
|
777
|
+
for item in data:
|
|
778
|
+
if isinstance(item, dict):
|
|
779
|
+
found = _normalize(item.get("refundOrderNo"))
|
|
780
|
+
if found:
|
|
781
|
+
return found
|
|
782
|
+
return None
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def cmd_refund_part(args: argparse.Namespace) -> int:
|
|
786
|
+
nights = nights_between(args.check_in, args.check_out)
|
|
787
|
+
if not nights:
|
|
788
|
+
return _fail(None, f"退订日期不合法:{args.check_in} → {args.check_out}(需连续且离店晚于入住)")
|
|
789
|
+
|
|
790
|
+
detail = _get(f"/order/{args.order_no}")
|
|
791
|
+
if detail.get("code") != "0":
|
|
792
|
+
return _fail(detail.get("code"), detail.get("msg"))
|
|
793
|
+
order = detail.get("data") or {}
|
|
794
|
+
print(f"订单 {args.order_no} 当前状态:{show_status_zh(order.get('showStatus'))}")
|
|
795
|
+
print(f" 拟退:房间 {args.room_no} {args.check_in} → {args.check_out}({nights} 晚)")
|
|
796
|
+
print(f" 订单总额:¥{order.get('totalAmount')} 已扣款:¥{order.get('deductedAmount')}")
|
|
797
|
+
|
|
798
|
+
if args.dry_run:
|
|
799
|
+
print()
|
|
800
|
+
print("(dry-run:未提交退订。实际退款金额以供应商生成的退单为准,受理后由轮询回填。)")
|
|
801
|
+
print("确认没问题后去掉 --dry-run 再执行一次。")
|
|
802
|
+
return 0
|
|
803
|
+
|
|
804
|
+
payload = build_partial_refund(args.room_no, args.check_in, args.check_out, args.reason)
|
|
805
|
+
res = _post(f"/order/{args.order_no}/refund", payload)
|
|
806
|
+
if res.get("code") != "0":
|
|
807
|
+
return _fail(res.get("code"), res.get("msg"))
|
|
808
|
+
|
|
809
|
+
refund_no = first_refund_order_no(res.get("data"))
|
|
810
|
+
if not refund_no:
|
|
811
|
+
print(json.dumps({
|
|
812
|
+
"ok": False,
|
|
813
|
+
"message": "退订响应里没有退单号,请立刻人工核对(不要重复提交)",
|
|
814
|
+
"data": res.get("data"),
|
|
815
|
+
}, ensure_ascii=False))
|
|
816
|
+
return 1
|
|
817
|
+
print(f"退订已受理 退单号:{refund_no}(上游先 AUDITING,等放款后入账)")
|
|
818
|
+
print("入账时刻取决于供应商放款,请间隔一段时间用 order-status 复查;"
|
|
819
|
+
"同一订单要发起下一笔退订,必须等上一笔入账完成。")
|
|
820
|
+
return 0
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
# ---------- 十、本地订单缓存 / 入住人管理 ----------
|
|
824
|
+
|
|
825
|
+
def cmd_list_orders(args: argparse.Namespace) -> int:
|
|
826
|
+
cache = _read_json(ORDERS_CACHE_FILE, {}) or {}
|
|
827
|
+
if not cache:
|
|
828
|
+
print("本地还没有订单记录。")
|
|
829
|
+
return 0
|
|
830
|
+
rows = []
|
|
831
|
+
for order_no, item in cache.items():
|
|
832
|
+
rows.append([
|
|
833
|
+
order_no, show_status_zh(item.get("showStatus")), item.get("hotelName"),
|
|
834
|
+
item.get("roomName"), f"{item.get('checkInDate') or '-'} → {item.get('checkOutDate') or '-'}",
|
|
835
|
+
item.get("totalAmount"),
|
|
836
|
+
])
|
|
837
|
+
_print_table(["订单号", "状态", "酒店", "房型", "入住 → 离店", "金额"],
|
|
838
|
+
rows, aligns=["left", "left", "left", "left", "left", "right"])
|
|
839
|
+
return 0
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _load_passengers() -> list:
|
|
843
|
+
return _read_json(PASSENGERS_FILE, []) or []
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def cmd_save_passenger(args: argparse.Namespace) -> int:
|
|
847
|
+
name = os.environ.get(ENV_GUEST_NAME, "").strip()
|
|
848
|
+
mobile = os.environ.get(ENV_GUEST_MOBILE, "").strip()
|
|
849
|
+
credential_no = os.environ.get(ENV_GUEST_CREDENTIAL_NO, "").strip()
|
|
850
|
+
if not (name and mobile and credential_no):
|
|
851
|
+
return _fail(None, f"请先设置环境变量 {ENV_GUEST_NAME} / {ENV_GUEST_MOBILE} / {ENV_GUEST_CREDENTIAL_NO}")
|
|
852
|
+
passengers = _load_passengers()
|
|
853
|
+
record = {"name": name, "mobile": mobile, "credentialNo": credential_no,
|
|
854
|
+
"savedAt": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|
855
|
+
for i, item in enumerate(passengers):
|
|
856
|
+
if item.get("credentialNo") == credential_no:
|
|
857
|
+
passengers[i] = record
|
|
858
|
+
break
|
|
859
|
+
else:
|
|
860
|
+
passengers.append(record)
|
|
861
|
+
_write_json(PASSENGERS_FILE, passengers)
|
|
862
|
+
print(json.dumps({"ok": True, "saved": {"name": name, "mobile": mobile}}, ensure_ascii=False))
|
|
863
|
+
return 0
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def cmd_list_passengers(args: argparse.Namespace) -> int:
|
|
867
|
+
passengers = _load_passengers()
|
|
868
|
+
if not passengers:
|
|
869
|
+
print("还没有保存过入住人身份。")
|
|
870
|
+
return 0
|
|
871
|
+
# 手机号与证件号打码,避免把身份信息原样摊在会话里
|
|
872
|
+
rows = []
|
|
873
|
+
for item in passengers:
|
|
874
|
+
mobile = str(item.get("mobile") or "")
|
|
875
|
+
masked = mobile[:3] + "*" * max(len(mobile) - 7, 0) + mobile[-4:] if len(mobile) >= 7 else mobile
|
|
876
|
+
rows.append([item.get("name"), masked, item.get("savedAt")])
|
|
877
|
+
_print_table(["姓名", "手机号", "保存时间"], rows)
|
|
878
|
+
return 0
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def main() -> int:
|
|
882
|
+
parser = argparse.ArgumentParser(description="酒店查询与预订")
|
|
883
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
884
|
+
|
|
885
|
+
p_city = sub.add_parser("search-city", help="关键词 / 地标 / 酒店名检索,返回候选城市与酒店 ID")
|
|
886
|
+
p_city.add_argument("--keyword", required=True, help="城市名、地标、商圈或酒店名,如 外滩")
|
|
887
|
+
p_city.add_argument("--city-id", default=None, help="可选,在途城市 ID(如 10801)")
|
|
888
|
+
p_city.add_argument("--city-name", default=None, help="可选,城市名(与 --city-id 同时给)")
|
|
889
|
+
p_city.add_argument("--page-no", type=int, default=None)
|
|
890
|
+
p_city.add_argument("--page-size", type=int, default=None)
|
|
891
|
+
p_city.set_defaults(func=cmd_search_city)
|
|
892
|
+
|
|
893
|
+
p_hotel = sub.add_parser("search-hotel", help="按酒店 ID 或 cityId 查起价列表")
|
|
894
|
+
p_hotel.add_argument("--check-in", required=True, help="入住日期 YYYY-MM-DD")
|
|
895
|
+
p_hotel.add_argument("--check-out", required=True, help="离店日期 YYYY-MM-DD")
|
|
896
|
+
p_hotel.add_argument("--hotel-ids", default=None, help="可选,逗号分隔的酒店 ID;默认用 search-city 的候选")
|
|
897
|
+
p_hotel.add_argument("--city-id", default=None, help="可选,条件搜索用(测试环境实测返空,仅作兜底)")
|
|
898
|
+
p_hotel.add_argument("--page-index", type=int, default=None)
|
|
899
|
+
p_hotel.add_argument("--page-size", type=int, default=None)
|
|
900
|
+
p_hotel.set_defaults(func=cmd_search_hotel)
|
|
901
|
+
|
|
902
|
+
p_detail = sub.add_parser("hotel-detail", help="查房型与价格计划(含逐晚价、库存、取消规则)")
|
|
903
|
+
p_detail.add_argument("--origin-hotel-id", required=True, help="在途酒店 ID")
|
|
904
|
+
p_detail.add_argument("--check-in", default=None, help="入住日期,默认沿用上次")
|
|
905
|
+
p_detail.add_argument("--check-out", default=None, help="离店日期,默认沿用上次")
|
|
906
|
+
p_detail.add_argument("--city-id", default=None, help="可选,会话城市 ID")
|
|
907
|
+
p_detail.set_defaults(func=cmd_hotel_detail)
|
|
908
|
+
|
|
909
|
+
p_validate = sub.add_parser("validate", help="预订校验(报价码是否仍有效)")
|
|
910
|
+
p_validate.add_argument("--room-id", default=None, help="房型 ID,默认取缓存里的第一行")
|
|
911
|
+
p_validate.add_argument("--rate-plan-id", default=None, help="价格计划 ID")
|
|
912
|
+
p_validate.add_argument("--rooms", type=int, default=1, help="房间数,默认 1")
|
|
913
|
+
p_validate.add_argument("--customer-num", type=int, default=1, help="入住人数,默认 1")
|
|
914
|
+
p_validate.set_defaults(func=cmd_validate)
|
|
915
|
+
|
|
916
|
+
p_create = sub.add_parser("create-order", help="创建订单(房间与日期取缓存;入住人取环境变量)")
|
|
917
|
+
p_create.add_argument("--room-id", default=None, help="房型 ID,默认取缓存里的第一行")
|
|
918
|
+
p_create.add_argument("--rate-plan-id", default=None, help="价格计划 ID")
|
|
919
|
+
p_create.add_argument("--rooms", type=int, default=1, help="房间数,默认 1")
|
|
920
|
+
p_create.add_argument("--credential-type", default="IDENTITY", help="证件类型,默认 IDENTITY")
|
|
921
|
+
p_create.add_argument("--contacter-name", default=None, help="联系人姓名,默认同入住人")
|
|
922
|
+
p_create.add_argument("--contacter-mobile", default=None, help="联系人手机号,默认同入住人")
|
|
923
|
+
p_create.add_argument("--earliest-arrival-time", default=None, help="最早到店时间,默认入住日 14:00:00")
|
|
924
|
+
p_create.add_argument("--latest-arrival-time", default=None, help="最晚到店时间,默认入住日 23:59:00")
|
|
925
|
+
p_create.set_defaults(func=cmd_create_order)
|
|
926
|
+
|
|
927
|
+
p_pay = sub.add_parser("pay-order", help="支付订单(企业账户)")
|
|
928
|
+
p_pay.add_argument("--order-no", required=True, help="订单号")
|
|
929
|
+
p_pay.add_argument("--pay-amount", type=float, default=None, help="可选,默认取订单详情的订单总额")
|
|
930
|
+
p_pay.set_defaults(func=cmd_pay_order)
|
|
931
|
+
|
|
932
|
+
p_status = sub.add_parser("order-status", help="查询订单状态与详情")
|
|
933
|
+
p_status.add_argument("--order-no", required=True, help="订单号")
|
|
934
|
+
p_status.set_defaults(func=cmd_order_status)
|
|
935
|
+
|
|
936
|
+
p_cancel = sub.add_parser("cancel-order", help="取消订单(未支付取消 / 已支付退订由网关按状态自动选择)")
|
|
937
|
+
p_cancel.add_argument("--order-no", required=True, help="订单号")
|
|
938
|
+
p_cancel.set_defaults(func=cmd_cancel_order)
|
|
939
|
+
|
|
940
|
+
p_refund = sub.add_parser("refund-part", help="部分退订(需先 --dry-run 展示,再经用户确认)")
|
|
941
|
+
p_refund.add_argument("--order-no", required=True, help="订单号")
|
|
942
|
+
p_refund.add_argument("--room-no", type=int, required=True, help="房间号,从 1 开始")
|
|
943
|
+
p_refund.add_argument("--check-in", required=True, help="退订起始日期 YYYY-MM-DD")
|
|
944
|
+
p_refund.add_argument("--check-out", required=True, help="退订结束日期 YYYY-MM-DD")
|
|
945
|
+
p_refund.add_argument("--reason", default=None, help="可选,退订原因")
|
|
946
|
+
p_refund.add_argument("--dry-run", action="store_true", default=False, help="只展示订单与退款信息,不提交")
|
|
947
|
+
p_refund.set_defaults(func=cmd_refund_part)
|
|
948
|
+
|
|
949
|
+
p_list = sub.add_parser("list-orders", help="列出本地缓存的历史订单摘要")
|
|
950
|
+
p_list.set_defaults(func=cmd_list_orders)
|
|
951
|
+
|
|
952
|
+
p_save_pax = sub.add_parser("save-passenger", help="把当前环境变量中的入住人身份保存到本地文件")
|
|
953
|
+
p_save_pax.set_defaults(func=cmd_save_passenger)
|
|
954
|
+
|
|
955
|
+
p_list_pax = sub.add_parser("list-passengers", help="列出已保存的入住人身份(手机号打码)")
|
|
956
|
+
p_list_pax.set_defaults(func=cmd_list_passengers)
|
|
957
|
+
|
|
958
|
+
args = parser.parse_args()
|
|
959
|
+
return args.func(args)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
if __name__ == "__main__":
|
|
963
|
+
sys.exit(main())
|