@miraiva_test/miravia-order-report 0.1.13
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/cli.js +61 -0
- package/dist/.keypair.json +5 -0
- package/dist/README.md +249 -0
- package/dist/SKILL.md +329 -0
- package/dist/VERSION +1 -0
- package/dist/executive_summary_prompt_v2.md +574 -0
- package/dist/reference.md +409 -0
- package/dist/requirements.txt +6 -0
- package/dist/scripts/auth_cli.py +1442 -0
- package/dist/scripts/config.py +216 -0
- package/dist/scripts/cred_crypto.py +235 -0
- package/dist/scripts/estimate_time.py +244 -0
- package/dist/scripts/export_orders.py +1549 -0
- package/dist/scripts/i18n.py +1162 -0
- package/dist/scripts/inject_insights.py +96 -0
- package/dist/scripts/login.bat +14 -0
- package/dist/scripts/miravia_client.py +468 -0
- package/dist/scripts/report_v4.py +1694 -0
- package/dist/scripts/run.bat +15 -0
- package/dist/scripts/skill_crypto.py +86 -0
- package/dist/scripts/skill_keypair.py +86 -0
- package/dist/scripts/skill_logger.py +262 -0
- package/dist/scripts/test_v4_render.py +322 -0
- package/dist/scripts/trace_logger.py +97 -0
- package/dist/scripts/validate_render.py +101 -0
- package/dist/static/chart.umd.min.js +20 -0
- package/dist/static/chartjs-plugin-datalabels.min.js +7 -0
- package/dist/static/miravia-logo.svg +61 -0
- package/dist/templates/report_v4.html +1005 -0
- package/lib/build-dist.js +86 -0
- package/lib/clean-dist.js +18 -0
- package/lib/installer.js +444 -0
- package/package.json +34 -0
|
@@ -0,0 +1,1549 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Miravia Order Report - Main Entry
|
|
5
|
+
|
|
6
|
+
Workflow:
|
|
7
|
+
1. Parse CLI arguments, normalize time range
|
|
8
|
+
2. Fetch main orders -> sub-orders -> reverse orders via MiraviaClient
|
|
9
|
+
3. Aggregate into sub-order granularity DataFrame, export Excel/CSV
|
|
10
|
+
4. Render HTML analysis report based on DataFrame
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import logging
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
# Allow direct `python scripts/export_orders.py` execution
|
|
24
|
+
THIS_DIR = Path(__file__).resolve().parent
|
|
25
|
+
sys.path.insert(0, str(THIS_DIR))
|
|
26
|
+
|
|
27
|
+
import config
|
|
28
|
+
from miravia_client import MiraviaClient, MiraviaError
|
|
29
|
+
from skill_logger import api_tracker, write_skill_log, set_agent_type
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("miravia.export")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# -------- Time utilities --------
|
|
36
|
+
|
|
37
|
+
def _parse_dt(s: str, end_of_day: bool = False) -> str:
|
|
38
|
+
"""Accept 'YYYY-MM-DD' or full ISO 8601; return ISO string with timezone offset."""
|
|
39
|
+
if not s:
|
|
40
|
+
raise ValueError("empty datetime")
|
|
41
|
+
if "T" in s:
|
|
42
|
+
return s # Assume caller provides valid ISO
|
|
43
|
+
base = datetime.strptime(s, "%Y-%m-%d")
|
|
44
|
+
if end_of_day:
|
|
45
|
+
base = base.replace(hour=23, minute=59, second=59)
|
|
46
|
+
return base.strftime("%Y-%m-%dT%H:%M:%S") + config.DEFAULT_TZ_OFFSET
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tz() -> timezone:
|
|
50
|
+
"""Derive tzinfo from config.DEFAULT_TZ_OFFSET, supports env var override (e.g., +02:00 / +01:00)."""
|
|
51
|
+
off = config.DEFAULT_TZ_OFFSET or "+02:00"
|
|
52
|
+
sign = 1 if off[0] == "+" else -1
|
|
53
|
+
hh, mm = off[1:].split(":")
|
|
54
|
+
return timezone(sign * timedelta(hours=int(hh), minutes=int(mm)))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_tz_offset(off_str: str):
|
|
58
|
+
"""Parse timezone offset string to (sign, hours, minutes).
|
|
59
|
+
|
|
60
|
+
Supports all common formats returned by Miravia API:
|
|
61
|
+
'+08:00' -> (1, 8, 0) # ISO 8601 with colon
|
|
62
|
+
'+0800' -> (1, 8, 0) # compact without colon
|
|
63
|
+
'-05:00' -> (-1, 5, 0)
|
|
64
|
+
'+02' -> (1, 2, 0) # hour-only
|
|
65
|
+
"""
|
|
66
|
+
sign = 1 if off_str[0] == "+" else -1
|
|
67
|
+
digits = off_str[1:].replace(":", "")
|
|
68
|
+
if len(digits) >= 4:
|
|
69
|
+
hh = int(digits[:2])
|
|
70
|
+
mm = int(digits[2:4])
|
|
71
|
+
elif len(digits) >= 2:
|
|
72
|
+
hh = int(digits[:2])
|
|
73
|
+
mm = 0
|
|
74
|
+
else:
|
|
75
|
+
hh = int(digits)
|
|
76
|
+
mm = 0
|
|
77
|
+
return sign, hh, mm
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _convert_to_local_tz(ts_val) -> str:
|
|
81
|
+
"""Convert API-returned timestamp to configured local timezone (config.DEFAULT_TZ_OFFSET).
|
|
82
|
+
|
|
83
|
+
Miravia API returns timestamps in server timezone (typically +0800 CST).
|
|
84
|
+
This normalizes them to the configured business timezone (default: +02:00 Madrid CEST).
|
|
85
|
+
|
|
86
|
+
Supports:
|
|
87
|
+
- ISO strings with colon offset: '2026-06-25T15:30:00+08:00' -> '2026-06-25T09:30:00+02:00'
|
|
88
|
+
- ISO strings compact offset: '2026-06-25 08:01:40 +0800' -> '2026-06-25T02:01:40+02:00'
|
|
89
|
+
- Unix timestamps (seconds/ms): 1782356394 -> '2026-06-24T23:59:54+02:00'
|
|
90
|
+
- Strings without offset: treated as already in configured timezone, offset appended
|
|
91
|
+
- None / empty: returned as-is
|
|
92
|
+
"""
|
|
93
|
+
if ts_val is None or ts_val == "":
|
|
94
|
+
return ts_val
|
|
95
|
+
|
|
96
|
+
# Handle numeric Unix timestamps (seconds or milliseconds)
|
|
97
|
+
if isinstance(ts_val, (int, float)):
|
|
98
|
+
ts_sec = ts_val / 1000.0 if ts_val > 9_999_999_999 else float(ts_val)
|
|
99
|
+
local_tz = _tz()
|
|
100
|
+
local_dt = datetime.fromtimestamp(ts_sec, tz=local_tz)
|
|
101
|
+
return local_dt.strftime("%Y-%m-%dT%H:%M:%S") + config.DEFAULT_TZ_OFFSET
|
|
102
|
+
|
|
103
|
+
s = str(ts_val).strip()
|
|
104
|
+
|
|
105
|
+
# Numeric string (e.g. "1782356394.0" from pandas float)
|
|
106
|
+
try:
|
|
107
|
+
num = float(s)
|
|
108
|
+
if num > 1_000_000_000: # Looks like a Unix timestamp
|
|
109
|
+
ts_sec = num / 1000.0 if num > 9_999_999_999 else num
|
|
110
|
+
local_tz = _tz()
|
|
111
|
+
local_dt = datetime.fromtimestamp(ts_sec, tz=local_tz)
|
|
112
|
+
return local_dt.strftime("%Y-%m-%dT%H:%M:%S") + config.DEFAULT_TZ_OFFSET
|
|
113
|
+
except (ValueError, TypeError, OverflowError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
if len(s) < 19:
|
|
117
|
+
return s # Too short to be a valid datetime
|
|
118
|
+
try:
|
|
119
|
+
# Normalize separator: support both 'T' and space between date and time
|
|
120
|
+
if "T" not in s[:20]:
|
|
121
|
+
s = s[:10] + "T" + s[11:19] + s[19:]
|
|
122
|
+
base = s[:19]
|
|
123
|
+
off_part = s[19:].strip()
|
|
124
|
+
dt = datetime.strptime(base, "%Y-%m-%dT%H:%M:%S")
|
|
125
|
+
if off_part and off_part != "Z" and off_part[0] in "+-":
|
|
126
|
+
sign, hh, mm = _parse_tz_offset(off_part)
|
|
127
|
+
src_tz = timezone(sign * timedelta(hours=hh, minutes=mm))
|
|
128
|
+
elif off_part == "Z":
|
|
129
|
+
src_tz = timezone.utc
|
|
130
|
+
else:
|
|
131
|
+
# No timezone info — treat as already in configured timezone
|
|
132
|
+
return base + config.DEFAULT_TZ_OFFSET
|
|
133
|
+
# If source timezone is already the configured one, skip conversion
|
|
134
|
+
local_tz = _tz()
|
|
135
|
+
if src_tz == local_tz:
|
|
136
|
+
return base + config.DEFAULT_TZ_OFFSET
|
|
137
|
+
# Convert to configured local timezone
|
|
138
|
+
local_dt = dt.replace(tzinfo=src_tz).astimezone(local_tz)
|
|
139
|
+
return local_dt.strftime("%Y-%m-%dT%H:%M:%S") + config.DEFAULT_TZ_OFFSET
|
|
140
|
+
except (ValueError, IndexError, AttributeError, TypeError):
|
|
141
|
+
return str(ts_val) # Parsing failed — return original
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _last_days_range(n: int) -> (str, str):
|
|
145
|
+
"""Semantics: whole-day range including today going back N days (aligned to config.DEFAULT_TZ_OFFSET timezone).
|
|
146
|
+
n=1 -> today 00:00:00 ~ today 23:59:59
|
|
147
|
+
n=7 -> today-6 00:00:00 ~ today 23:59:59
|
|
148
|
+
n<1 -> corrected to 1 with warning.
|
|
149
|
+
"""
|
|
150
|
+
if n < 1:
|
|
151
|
+
print(f"warning: --last-days={n} is invalid, falling back to 1.", file=sys.stderr)
|
|
152
|
+
n = 1
|
|
153
|
+
now = datetime.now(_tz())
|
|
154
|
+
end = now.replace(hour=23, minute=59, second=59, microsecond=0)
|
|
155
|
+
start = (now - timedelta(days=n - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
156
|
+
return (start.strftime("%Y-%m-%dT%H:%M:%S") + config.DEFAULT_TZ_OFFSET,
|
|
157
|
+
end.strftime("%Y-%m-%dT%H:%M:%S") + config.DEFAULT_TZ_OFFSET)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _iso_to_ms(s: str) -> int:
|
|
161
|
+
"""ISO string with timezone offset -> Unix millisecond timestamp."""
|
|
162
|
+
# e.g. '2026-04-28T00:00:00+08:00'
|
|
163
|
+
dt = datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S")
|
|
164
|
+
off = s[19:] or config.DEFAULT_TZ_OFFSET
|
|
165
|
+
sign = 1 if off[0] == "+" else -1
|
|
166
|
+
hh, mm = off[1:].split(":")
|
|
167
|
+
tz = timezone(sign * timedelta(hours=int(hh), minutes=int(mm)))
|
|
168
|
+
return int(dt.replace(tzinfo=tz).timestamp() * 1000)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_NULL_SENTINELS = (None, "", "null", "null-null", "NULL", "None")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _is_blank(v: Any) -> bool:
|
|
175
|
+
"""Miravia often returns string sentinels like 'null-null' to represent null values."""
|
|
176
|
+
return v in _NULL_SENTINELS
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _safe_filename(s: str) -> str:
|
|
180
|
+
"""Generate filename-safe ISO string. Preserves timezone offset without truncation.
|
|
181
|
+
|
|
182
|
+
Processing: separates date part from timezone offset, safely replaces each,
|
|
183
|
+
avoiding incorrect removal of '-' in negative timezones (e.g., -05:00).
|
|
184
|
+
"""
|
|
185
|
+
import re
|
|
186
|
+
# Match trailing timezone offset (+08:00 / -05:00 / Z)
|
|
187
|
+
m = re.match(r'^(.*?)([+-]\d{2}:\d{2}|Z)?$', s)
|
|
188
|
+
if m:
|
|
189
|
+
base, tz = m.group(1), m.group(2) or ""
|
|
190
|
+
base = base.replace(":", "").replace("T", "_")
|
|
191
|
+
tz = tz.replace(":", "").replace("+", "p").replace("-", "m")
|
|
192
|
+
return base + tz
|
|
193
|
+
return s.replace(":", "").replace("+", "p").replace("-", "m").replace("T", "_")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _resolve_collision(path: Path) -> Path:
|
|
197
|
+
"""If same-named file already exists (same-second re-run), add _1, _2 suffix to avoid overwriting."""
|
|
198
|
+
if not path.exists():
|
|
199
|
+
return path
|
|
200
|
+
stem, suffix = path.stem, path.suffix
|
|
201
|
+
parent = path.parent
|
|
202
|
+
for i in range(1, 100):
|
|
203
|
+
cand = parent / f"{stem}_{i}{suffix}"
|
|
204
|
+
if not cand.exists():
|
|
205
|
+
return cand
|
|
206
|
+
return path # Extreme case, return original path
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _cents_to_currency(v):
|
|
210
|
+
"""The reverse order API /reverse/getreverseordersforseller returns refund_amount
|
|
211
|
+
in smallest currency units (cents), needs /100 to get readable currency amount.
|
|
212
|
+
|
|
213
|
+
Example: API returns 25562 -> 255.62 EUR.
|
|
214
|
+
/orders/items/get fields like item_price / paid_price / voucher_amount
|
|
215
|
+
are already in currency units and don't need conversion; this function
|
|
216
|
+
is exclusively for reverse order amounts.
|
|
217
|
+
"""
|
|
218
|
+
if v is None or v == "":
|
|
219
|
+
return None
|
|
220
|
+
if isinstance(v, bool):
|
|
221
|
+
return v # Keep boolean as-is to avoid float(True)=1.0 misconversion
|
|
222
|
+
try:
|
|
223
|
+
return round(float(v) / 100.0, 2)
|
|
224
|
+
except (TypeError, ValueError):
|
|
225
|
+
return v
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# -------- data aggregation --------
|
|
229
|
+
|
|
230
|
+
def _pick_latest_reverse(rev_list: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
231
|
+
"""Pick the latest reverse order from a list by creation time.
|
|
232
|
+
|
|
233
|
+
Business rule: under current Miravia reverse logic, if an order has one
|
|
234
|
+
successful refund, a second refund cannot be initiated. So when multiple
|
|
235
|
+
reverse orders exist for the same item, the earlier ones are all cancelled
|
|
236
|
+
— only the latest one is meaningful.
|
|
237
|
+
|
|
238
|
+
Selection priority:
|
|
239
|
+
1. Sort by apply_time / create_time descending, pick the first.
|
|
240
|
+
2. If timestamps are all missing, pick the last element (API returns
|
|
241
|
+
chronological order, so last = newest).
|
|
242
|
+
"""
|
|
243
|
+
if not rev_list:
|
|
244
|
+
return {}
|
|
245
|
+
if len(rev_list) == 1:
|
|
246
|
+
return rev_list[0]
|
|
247
|
+
|
|
248
|
+
def _extract_ts(r: Dict[str, Any]):
|
|
249
|
+
"""Extract comparable timestamp string (ISO or epoch)."""
|
|
250
|
+
ts = r.get("apply_time") or r.get("create_time")
|
|
251
|
+
if ts is None or ts == "":
|
|
252
|
+
return ""
|
|
253
|
+
return str(ts)
|
|
254
|
+
|
|
255
|
+
# Sort descending by timestamp; empty timestamps sink to bottom
|
|
256
|
+
sorted_list = sorted(rev_list, key=lambda r: _extract_ts(r), reverse=True)
|
|
257
|
+
# Pick the one with the latest non-empty timestamp; fallback to last in original list
|
|
258
|
+
for r in sorted_list:
|
|
259
|
+
if _extract_ts(r):
|
|
260
|
+
return r
|
|
261
|
+
return rev_list[-1]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _flatten(orders: List[Dict[str, Any]],
|
|
265
|
+
items_by_order: Dict[str, Dict[str, Any]],
|
|
266
|
+
reverse_by_item: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
|
267
|
+
"""Generate sub-order granularity rows. See reference.md for field mapping.
|
|
268
|
+
|
|
269
|
+
Reverse order deduplication: each item produces exactly 1 row.
|
|
270
|
+
When a single item has multiple reverse orders, only the LATEST one
|
|
271
|
+
(by creation time) is used for refund fields. Business rationale:
|
|
272
|
+
under current Miravia rules, only one successful refund is allowed per item;
|
|
273
|
+
earlier reverse orders are always cancelled.
|
|
274
|
+
"""
|
|
275
|
+
rows: List[Dict[str, Any]] = []
|
|
276
|
+
for o in orders:
|
|
277
|
+
oid = str(o.get("order_id"))
|
|
278
|
+
addr = o.get("address_shipping") or {}
|
|
279
|
+
entry = items_by_order.get(oid) or {}
|
|
280
|
+
items = entry.get("items") or []
|
|
281
|
+
# buyer_remark / seller_remark actual source fields come from /orders/items/get response entry
|
|
282
|
+
entry_buyer_remark = entry.get("buyer_remark") or ""
|
|
283
|
+
entry_seller_remark = entry.get("seller_remark") or ""
|
|
284
|
+
first_name = o.get("customer_first_name") or ""
|
|
285
|
+
last_name = o.get("customer_last_name") or ""
|
|
286
|
+
buyer_name = (first_name + " " + last_name).strip() or None
|
|
287
|
+
for it in items:
|
|
288
|
+
iid = str(it.get("order_item_id"))
|
|
289
|
+
raw_rev_list = reverse_by_item.get(iid) or []
|
|
290
|
+
# Pick only the latest reverse order (dedup: 1 row per item)
|
|
291
|
+
rev = _pick_latest_reverse(raw_rev_list) if raw_rev_list else {}
|
|
292
|
+
cancel_initiator_raw = it.get("cancel_return_initiator")
|
|
293
|
+
cancel_initiator = None if _is_blank(cancel_initiator_raw) else cancel_initiator_raw
|
|
294
|
+
api_qty = it.get("quantity")
|
|
295
|
+
try:
|
|
296
|
+
qty = int(api_qty) if api_qty not in (None, "") else 1
|
|
297
|
+
except (TypeError, ValueError):
|
|
298
|
+
qty = 1
|
|
299
|
+
rows.append({
|
|
300
|
+
# order-level basics
|
|
301
|
+
"order_id": oid,
|
|
302
|
+
"marketplace": o.get("marketplace"),
|
|
303
|
+
"created_at": _convert_to_local_tz(o.get("created_at")),
|
|
304
|
+
"items_count": o.get("items_count"),
|
|
305
|
+
"payment_method": o.get("payment_method"),
|
|
306
|
+
"buyer_id": o.get("buyer_id"), # prefer real buyer_id; may be None under invite-test token
|
|
307
|
+
"buyer_name": buyer_name, # concatenated name, no longer mixed with ID
|
|
308
|
+
"ship_address": ", ".join(filter(None, [
|
|
309
|
+
addr.get("address1"), addr.get("address2"),
|
|
310
|
+
addr.get("address3"), addr.get("city"),
|
|
311
|
+
addr.get("post_code"), addr.get("country"),
|
|
312
|
+
])),
|
|
313
|
+
"ship_country": addr.get("country"),
|
|
314
|
+
"warehouse_code": o.get("warehouse_code"), # no longer fallback to delivery_info
|
|
315
|
+
"buyer_remark": entry_buyer_remark,
|
|
316
|
+
"seller_remark": entry_seller_remark,
|
|
317
|
+
# item-level basics
|
|
318
|
+
"order_item_id": iid,
|
|
319
|
+
"item_status": it.get("status"),
|
|
320
|
+
"item_updated_at": _convert_to_local_tz(it.get("updated_at")),
|
|
321
|
+
# product
|
|
322
|
+
"product_id": it.get("product_id"),
|
|
323
|
+
"product_name": it.get("name"),
|
|
324
|
+
"product_url": it.get("product_main_image"),
|
|
325
|
+
"sku": it.get("sku"),
|
|
326
|
+
"shop_sku": it.get("shop_sku"),
|
|
327
|
+
"quantity": qty,
|
|
328
|
+
"unit_price": it.get("item_price"),
|
|
329
|
+
# pricing
|
|
330
|
+
"buyer_paid": it.get("paid_price"),
|
|
331
|
+
"discount_total": it.get("voucher_amount"),
|
|
332
|
+
"discount_platform": it.get("voucher_platform"),
|
|
333
|
+
"discount_seller": it.get("voucher_seller"),
|
|
334
|
+
"shipping_paid": it.get("shipping_amount"),
|
|
335
|
+
"shipping_disc_total": it.get("shipping_amount_voucher"),
|
|
336
|
+
"shipping_disc_platform": it.get("shipping_service_cost"),
|
|
337
|
+
"shipping_disc_seller": it.get("shipping_amount_seller"),
|
|
338
|
+
"currency": it.get("currency") or o.get("currency"),
|
|
339
|
+
# logistics
|
|
340
|
+
"shipment_type": it.get("shipment_type"),
|
|
341
|
+
"tracking_no": it.get("tracking_code"),
|
|
342
|
+
"package_id": it.get("package_id"),
|
|
343
|
+
"shipping_provider": it.get("shipping_provider_type") or it.get("shipping_provider"),
|
|
344
|
+
# cancellation: cancel_return_initiator is "null-null" sentinel when not cancelled
|
|
345
|
+
"is_canceled": (cancel_initiator is not None) or
|
|
346
|
+
(it.get("status") in ("canceled", "cancelled")),
|
|
347
|
+
"cancel_initiator": cancel_initiator,
|
|
348
|
+
"cancel_reason": it.get("reason") or it.get("reason_detail") or None,
|
|
349
|
+
# refund
|
|
350
|
+
"refund_id": rev.get("reverse_order_id") or rev.get("dispute_id"),
|
|
351
|
+
"refund_apply_at": _convert_to_local_tz(rev.get("apply_time") or rev.get("create_time")),
|
|
352
|
+
"refund_status": rev.get("status"),
|
|
353
|
+
# NOTE: reverse order API refund_amount is in smallest currency units (cents), must /100
|
|
354
|
+
"refund_amount": _cents_to_currency(rev.get("refund_amount")),
|
|
355
|
+
"refund_reason": rev.get("reason"),
|
|
356
|
+
"is_fast_refund": rev.get("fast_refund"),
|
|
357
|
+
"is_instant_refund": rev.get("instant_refund"),
|
|
358
|
+
"refund_tracking_no": rev.get("return_tracking_no"),
|
|
359
|
+
# ---- v4 new fields ----
|
|
360
|
+
# address/region
|
|
361
|
+
"city": addr.get("city") or addr.get("City"),
|
|
362
|
+
"ship_to": it.get("ship_to") or it.get("shipTo") or addr.get("country"),
|
|
363
|
+
# channel/tags
|
|
364
|
+
"channel": it.get("channel") or o.get("channel") or it.get("marketplace") or o.get("marketplace"),
|
|
365
|
+
"order_flag": it.get("order_flag") or it.get("orderFlag"),
|
|
366
|
+
# logistics attributes
|
|
367
|
+
"is_fbl": it.get("is_fbl") or it.get("isFBL") or 0,
|
|
368
|
+
"is_digital": it.get("is_digital") or it.get("IsDigital") or 0,
|
|
369
|
+
"is_sof": it.get("is_sof") or it.get("isSOF") or 0,
|
|
370
|
+
"shipping_fee_original": it.get("shipping_fee_original") or it.get("shippingFeeOriginal"),
|
|
371
|
+
"shipping_fee_disc_seller": it.get("shipping_fee_discount_seller") or it.get("shippingFeeDiscountSeller"),
|
|
372
|
+
# product/discount
|
|
373
|
+
"voucher_seller_lpi": it.get("voucher_seller_lpi") or it.get("voucherSellerLPI"),
|
|
374
|
+
"variation": it.get("variation") or it.get("Variation"),
|
|
375
|
+
"returnable": it.get("returnable", True),
|
|
376
|
+
# reverse order extension (v4)
|
|
377
|
+
"request_type": rev.get("request_type"),
|
|
378
|
+
"duty_party": rev.get("duty_party"),
|
|
379
|
+
"is_not_received": rev.get("is_not_received"),
|
|
380
|
+
"refund_payment_method": rev.get("refund_payment_method"),
|
|
381
|
+
"reason_code": rev.get("reason_code"),
|
|
382
|
+
"ofc_status": rev.get("ofc_status"),
|
|
383
|
+
"refund_currency": rev.get("refund_currency"),
|
|
384
|
+
"seller_sku_id": rev.get("seller_sku_id"),
|
|
385
|
+
"seller_agreed": rev.get("status") in (
|
|
386
|
+
"AGREE_CANCEL_ORDER", "SELLER_AGREE_RETURN", "SELLER_AGREE_REFUND",
|
|
387
|
+
) if rev.get("status") else None,
|
|
388
|
+
})
|
|
389
|
+
return rows
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# -------- Excel/CSV export --------
|
|
393
|
+
|
|
394
|
+
EXPORT_COLUMNS = [
|
|
395
|
+
# order-level
|
|
396
|
+
"order_id", "marketplace", "created_at", "items_count", "payment_method",
|
|
397
|
+
"buyer_id", "buyer_name", "ship_address", "ship_country", "warehouse_code",
|
|
398
|
+
"buyer_remark", "seller_remark",
|
|
399
|
+
# item-level
|
|
400
|
+
"order_item_id", "item_status", "item_updated_at",
|
|
401
|
+
# product
|
|
402
|
+
"product_id", "product_name", "product_url", "sku", "shop_sku",
|
|
403
|
+
"quantity", "unit_price",
|
|
404
|
+
# pricing
|
|
405
|
+
"buyer_paid", "discount_total", "discount_platform", "discount_seller",
|
|
406
|
+
"shipping_paid", "shipping_disc_total", "shipping_disc_platform",
|
|
407
|
+
"shipping_disc_seller", "currency",
|
|
408
|
+
# logistics
|
|
409
|
+
"shipment_type", "tracking_no", "package_id", "shipping_provider",
|
|
410
|
+
# cancellation
|
|
411
|
+
"is_canceled", "cancel_initiator", "cancel_reason",
|
|
412
|
+
# refund
|
|
413
|
+
"refund_id", "refund_apply_at", "refund_status", "refund_amount",
|
|
414
|
+
"refund_reason", "is_fast_refund", "is_instant_refund",
|
|
415
|
+
"refund_tracking_no",
|
|
416
|
+
# ---- v4 additions (hidden in merchant Excel, used by HTML report) ----
|
|
417
|
+
"city", "ship_to", "channel", "order_flag",
|
|
418
|
+
"is_fbl", "is_digital", "is_sof",
|
|
419
|
+
"shipping_fee_original", "shipping_fee_disc_seller",
|
|
420
|
+
"voucher_seller_lpi", "variation", "returnable",
|
|
421
|
+
"request_type", "duty_party", "is_not_received",
|
|
422
|
+
"refund_payment_method", "reason_code", "ofc_status",
|
|
423
|
+
"refund_currency", "seller_sku_id", "seller_agreed",
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
# PRD-defined visible columns for merchant Excel export.
|
|
427
|
+
# Only these columns are visible; v4 additions are hidden in Excel.
|
|
428
|
+
PRD_VISIBLE_COLUMNS = [
|
|
429
|
+
# order-level basics
|
|
430
|
+
"order_id", "marketplace", "created_at", "items_count", "payment_method",
|
|
431
|
+
"buyer_id", "buyer_name", "ship_address", "ship_country", "warehouse_code",
|
|
432
|
+
"buyer_remark", "seller_remark",
|
|
433
|
+
# item-level basics
|
|
434
|
+
"order_item_id", "item_status", "item_updated_at",
|
|
435
|
+
# product
|
|
436
|
+
"product_id", "product_name", "product_url", "sku", "shop_sku",
|
|
437
|
+
"quantity", "unit_price",
|
|
438
|
+
# pricing
|
|
439
|
+
"buyer_paid", "discount_total", "discount_platform", "discount_seller",
|
|
440
|
+
"shipping_paid", "shipping_disc_total", "shipping_disc_platform",
|
|
441
|
+
"shipping_disc_seller", "currency",
|
|
442
|
+
# logistics
|
|
443
|
+
"shipment_type", "tracking_no", "package_id", "shipping_provider",
|
|
444
|
+
# cancellation
|
|
445
|
+
"is_canceled", "cancel_initiator", "cancel_reason",
|
|
446
|
+
# refund
|
|
447
|
+
"refund_id", "refund_apply_at", "refund_status", "refund_amount",
|
|
448
|
+
"refund_reason", "is_fast_refund", "is_instant_refund",
|
|
449
|
+
"refund_tracking_no",
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
# Excel header rename mapping: internal column name -> PRD Excel column name
|
|
453
|
+
# These renames only apply to Excel export headers, not to internal data.
|
|
454
|
+
EXCEL_HEADER_RENAME = {
|
|
455
|
+
"refund_id": "reverse_order_id",
|
|
456
|
+
"refund_apply_at": "return_order_line_gmt_create",
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# These columns are large numeric string IDs/codes; must be written as text to avoid Excel scientific notation or precision loss
|
|
461
|
+
TEXT_ID_COLUMNS = [
|
|
462
|
+
"order_id", "order_item_id", "product_id", "sku", "shop_sku",
|
|
463
|
+
"package_id", "tracking_no", "refund_id", "refund_tracking_no",
|
|
464
|
+
"buyer_id",
|
|
465
|
+
]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def export_dataframe(rows: List[Dict[str, Any]], out_path: Path, fmt: str) -> Path:
|
|
469
|
+
"""Export rows to Excel/CSV.
|
|
470
|
+
|
|
471
|
+
For Excel (xlsx): all EXPORT_COLUMNS are written, but only PRD_VISIBLE_COLUMNS
|
|
472
|
+
are visible; v4 additions are hidden columns (data preserved for internal use).
|
|
473
|
+
Column headers are renamed per EXCEL_HEADER_RENAME to match PRD naming.
|
|
474
|
+
|
|
475
|
+
For CSV: only PRD_VISIBLE_COLUMNS are exported (no hidden column support).
|
|
476
|
+
"""
|
|
477
|
+
import pandas as pd
|
|
478
|
+
df = pd.DataFrame(rows, columns=EXPORT_COLUMNS)
|
|
479
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
480
|
+
|
|
481
|
+
# Convert ID columns to pure strings to prevent pandas from inferring as numeric
|
|
482
|
+
def _to_text_id(v):
|
|
483
|
+
if v is None:
|
|
484
|
+
return ""
|
|
485
|
+
if isinstance(v, float):
|
|
486
|
+
if pd.isna(v):
|
|
487
|
+
return ""
|
|
488
|
+
# int-like float: remove trailing .0
|
|
489
|
+
if v.is_integer():
|
|
490
|
+
return str(int(v))
|
|
491
|
+
return repr(v)
|
|
492
|
+
return str(v)
|
|
493
|
+
|
|
494
|
+
for col in TEXT_ID_COLUMNS:
|
|
495
|
+
if col in df.columns:
|
|
496
|
+
df[col] = df[col].apply(_to_text_id)
|
|
497
|
+
|
|
498
|
+
if fmt == "csv":
|
|
499
|
+
# CSV: export only PRD visible columns (no hidden column support in CSV)
|
|
500
|
+
df_csv = df[[c for c in PRD_VISIBLE_COLUMNS if c in df.columns]].copy()
|
|
501
|
+
df_csv = df_csv.rename(columns=EXCEL_HEADER_RENAME)
|
|
502
|
+
df_csv.to_csv(out_path, index=False, encoding="utf-8-sig")
|
|
503
|
+
else:
|
|
504
|
+
# Excel: write all columns, rename headers, then hide non-PRD columns
|
|
505
|
+
df_excel = df.rename(columns=EXCEL_HEADER_RENAME)
|
|
506
|
+
with pd.ExcelWriter(out_path, engine="openpyxl") as writer:
|
|
507
|
+
df_excel.to_excel(writer, index=False, sheet_name="orders")
|
|
508
|
+
ws = writer.sheets["orders"]
|
|
509
|
+
from openpyxl.utils import get_column_letter
|
|
510
|
+
header = list(df_excel.columns)
|
|
511
|
+
|
|
512
|
+
# Set text format for ID columns (use renamed header names)
|
|
513
|
+
renamed_text_cols = [
|
|
514
|
+
EXCEL_HEADER_RENAME.get(c, c) for c in TEXT_ID_COLUMNS
|
|
515
|
+
]
|
|
516
|
+
for col_name in renamed_text_cols:
|
|
517
|
+
if col_name not in header:
|
|
518
|
+
continue
|
|
519
|
+
col_idx = header.index(col_name) + 1 # 1-based
|
|
520
|
+
col_letter = get_column_letter(col_idx)
|
|
521
|
+
# Row 1 is header, set text format starting from row 2
|
|
522
|
+
for row_idx in range(2, ws.max_row + 1):
|
|
523
|
+
cell = ws[f"{col_letter}{row_idx}"]
|
|
524
|
+
cell.number_format = "@"
|
|
525
|
+
|
|
526
|
+
# Hide non-PRD columns (v4 additions)
|
|
527
|
+
# Determine visible column names after rename
|
|
528
|
+
prd_renamed = set(
|
|
529
|
+
EXCEL_HEADER_RENAME.get(c, c) for c in PRD_VISIBLE_COLUMNS
|
|
530
|
+
)
|
|
531
|
+
for idx, col_name in enumerate(header):
|
|
532
|
+
col_letter = get_column_letter(idx + 1)
|
|
533
|
+
if col_name not in prd_renamed:
|
|
534
|
+
ws.column_dimensions[col_letter].hidden = True
|
|
535
|
+
return out_path
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# -------- HTML report --------
|
|
539
|
+
|
|
540
|
+
def _jinja_env():
|
|
541
|
+
from jinja2 import Environment, FileSystemLoader
|
|
542
|
+
return Environment(
|
|
543
|
+
loader=FileSystemLoader(str(THIS_DIR.parent / "templates")),
|
|
544
|
+
autoescape=True,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def render_v4_report(rows: List[Dict[str, Any]], out_path: Path,
|
|
549
|
+
range_label: str, lang: str = "en") -> Path:
|
|
550
|
+
"""v4 unified dashboard: merges business analysis + refund report into one interactive dashboard.
|
|
551
|
+
|
|
552
|
+
Data injection: Python pre-computes all metrics, injected as window.DATA = {...} into HTML;
|
|
553
|
+
frontend JS handles time range filtering and chart rendering.
|
|
554
|
+
"""
|
|
555
|
+
import report_v4
|
|
556
|
+
return report_v4.render(
|
|
557
|
+
rows=rows,
|
|
558
|
+
out_path=out_path,
|
|
559
|
+
range_label=range_label,
|
|
560
|
+
jinja_env=_jinja_env(),
|
|
561
|
+
export_columns=EXPORT_COLUMNS,
|
|
562
|
+
default_max_orders=config.DEFAULT_MAX_ORDERS,
|
|
563
|
+
lang=lang,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# -------- inspect scenario: single-order text diagnostics --------
|
|
571
|
+
|
|
572
|
+
def _fmt(v: Any, default: str = "-") -> str:
|
|
573
|
+
if v is None or _is_blank(v):
|
|
574
|
+
return default
|
|
575
|
+
return str(v)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _format_inspect_block(oid: str,
|
|
579
|
+
entry: Optional[Dict[str, Any]],
|
|
580
|
+
rev_list: List[Dict[str, Any]]) -> str:
|
|
581
|
+
"""Render diagnostic text for a single order (can be relayed directly to seller by Agent)."""
|
|
582
|
+
lines: List[str] = []
|
|
583
|
+
lines.append(f"========== Order {oid} ==========")
|
|
584
|
+
if not entry:
|
|
585
|
+
lines.append(" \u26a0 Order not found in /orders/items/get response. Please verify the order ID belongs to the currently authorized store.")
|
|
586
|
+
lines.append("=============================================")
|
|
587
|
+
return "\n".join(lines)
|
|
588
|
+
|
|
589
|
+
items = entry.get("order_items")
|
|
590
|
+
if items is None:
|
|
591
|
+
items = entry.get("items")
|
|
592
|
+
if items is None:
|
|
593
|
+
items = []
|
|
594
|
+
lines.append(f" Sub-orders : {len(items)}")
|
|
595
|
+
for it in items:
|
|
596
|
+
iid = _fmt(it.get("order_item_id"))
|
|
597
|
+
status = _fmt(it.get("status"))
|
|
598
|
+
cur = _fmt(it.get("currency"), "")
|
|
599
|
+
price = _fmt(it.get("paid_price"))
|
|
600
|
+
name = _fmt(it.get("name"))
|
|
601
|
+
lines.append(f" - item={iid} [{status}] {cur} {price} {name}")
|
|
602
|
+
|
|
603
|
+
track = it.get("tracking_code")
|
|
604
|
+
provider = it.get("shipping_provider_type") or it.get("shipping_provider")
|
|
605
|
+
ship_type = it.get("shipment_type")
|
|
606
|
+
if track or provider or ship_type:
|
|
607
|
+
lines.append(
|
|
608
|
+
f" Logistics : tracking={_fmt(track)} provider={_fmt(provider)} "
|
|
609
|
+
f"shipment_type={_fmt(ship_type)} package_id={_fmt(it.get('package_id'))}"
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
cancel_initiator = it.get("cancel_return_initiator")
|
|
613
|
+
if cancel_initiator and not _is_blank(cancel_initiator):
|
|
614
|
+
lines.append(
|
|
615
|
+
f" Cancel : initiator={cancel_initiator} "
|
|
616
|
+
f"reason={_fmt(it.get('reason') or it.get('reason_detail'))}"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
if not rev_list:
|
|
620
|
+
lines.append(" Refund : no reverse orders")
|
|
621
|
+
else:
|
|
622
|
+
lines.append(f" Refund : {len(rev_list)} reverse order(s)")
|
|
623
|
+
for r in rev_list:
|
|
624
|
+
rid = _fmt(r.get("reverse_order_id") or r.get("dispute_id"))
|
|
625
|
+
req_type = _fmt(r.get("request_type"))
|
|
626
|
+
refund_pm = _fmt(r.get("refund_payment_method"))
|
|
627
|
+
ofc = _fmt(r.get("ofc_status"))
|
|
628
|
+
inner_lines = r.get("reverse_order_lines") or []
|
|
629
|
+
if not inner_lines:
|
|
630
|
+
lines.append(
|
|
631
|
+
f" - reverse_order_id={rid} type={req_type} ofc={ofc} payment={refund_pm}"
|
|
632
|
+
)
|
|
633
|
+
continue
|
|
634
|
+
for ln in inner_lines:
|
|
635
|
+
status = _fmt(ln.get("reverse_status"))
|
|
636
|
+
amt = ln.get("refund_amount")
|
|
637
|
+
cur = _fmt(ln.get("refund_currency") or r.get("refund_currency"), "")
|
|
638
|
+
reason = _fmt(ln.get("reason_text"))
|
|
639
|
+
rtrack = _fmt(ln.get("tracking_number"))
|
|
640
|
+
lines.append(f" - reverse_order_id={rid} type={req_type} status={status}")
|
|
641
|
+
lines.append(f" Refund reason : {reason}")
|
|
642
|
+
lines.append(f" Refund amount : {_fmt(amt)} {cur} (payment_method={refund_pm})")
|
|
643
|
+
lines.append(f" Return status : ofc={ofc} return_tracking={rtrack}")
|
|
644
|
+
fast = ln.get("is_fast_refund")
|
|
645
|
+
instant = ln.get("is_instant_refund")
|
|
646
|
+
if fast is not None or instant is not None:
|
|
647
|
+
lines.append(
|
|
648
|
+
f" Fast refund : fast_refund={_fmt(fast)} instant_refund={_fmt(instant)}"
|
|
649
|
+
)
|
|
650
|
+
lines.append("=============================================")
|
|
651
|
+
return "\n".join(lines)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def run_inspect(args: argparse.Namespace, client: MiraviaClient) -> int:
|
|
655
|
+
"""inspect scenario: query order status/logistics/cancel/refund by order IDs, output structured text to stdout. No files written."""
|
|
656
|
+
raw = (args.order_ids or "").strip()
|
|
657
|
+
if not raw:
|
|
658
|
+
print("error: --scenario inspect requires --order-ids "
|
|
659
|
+
"(comma-separated trade order ids)", file=sys.stderr)
|
|
660
|
+
return 1
|
|
661
|
+
order_ids = [s.strip() for s in raw.split(",") if s.strip()]
|
|
662
|
+
if not order_ids:
|
|
663
|
+
print("error: --order-ids is empty after parsing", file=sys.stderr)
|
|
664
|
+
return 1
|
|
665
|
+
|
|
666
|
+
print(f"scenario : inspect (order_ids={len(order_ids)})")
|
|
667
|
+
|
|
668
|
+
# 1. Fetch item details/status/logistics (official API supports batch)
|
|
669
|
+
items_resp = client.get_multi_order_items(order_ids)
|
|
670
|
+
items_by_order: Dict[str, Dict[str, Any]] = {}
|
|
671
|
+
for entry in items_resp:
|
|
672
|
+
oid = str(entry.get("order_id"))
|
|
673
|
+
items_by_order[oid] = entry
|
|
674
|
+
|
|
675
|
+
# 2. Reverse orders must be fetched per-order: /reverse API trade_order_id is single-value filter
|
|
676
|
+
reverse_by_order: Dict[str, List[Dict[str, Any]]] = {}
|
|
677
|
+
for oid in order_ids:
|
|
678
|
+
try:
|
|
679
|
+
rev_list = client.get_reverse_orders(
|
|
680
|
+
trade_order_id=oid,
|
|
681
|
+
page_size=100,
|
|
682
|
+
max_pages=3,
|
|
683
|
+
)
|
|
684
|
+
except (MiraviaError, ValueError) as e:
|
|
685
|
+
logger.warning("fetch reverse for %s failed: %s", oid, e)
|
|
686
|
+
rev_list = []
|
|
687
|
+
reverse_by_order[oid] = rev_list
|
|
688
|
+
|
|
689
|
+
# 3. Render output
|
|
690
|
+
print()
|
|
691
|
+
for oid in order_ids:
|
|
692
|
+
block = _format_inspect_block(
|
|
693
|
+
oid,
|
|
694
|
+
items_by_order.get(oid),
|
|
695
|
+
reverse_by_order.get(oid, []),
|
|
696
|
+
)
|
|
697
|
+
print(block)
|
|
698
|
+
print()
|
|
699
|
+
return 0
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# -------- time-window auto-splitting --------
|
|
703
|
+
|
|
704
|
+
def _split_time_range(after: str, before: str) -> List[tuple]:
|
|
705
|
+
"""Split [after, before] into weekly (week_start, week_end) tuples.
|
|
706
|
+
|
|
707
|
+
Each window is 7 days (last window may be shorter).
|
|
708
|
+
Compared to daily splitting (365 windows), weekly splitting only needs ~52 windows,
|
|
709
|
+
greatly reducing API call count.
|
|
710
|
+
"""
|
|
711
|
+
from datetime import datetime, timedelta
|
|
712
|
+
fmt = "%Y-%m-%dT%H:%M:%S"
|
|
713
|
+
tz_off = config.DEFAULT_TZ_OFFSET
|
|
714
|
+
start_dt = datetime.strptime(after[:19], fmt)
|
|
715
|
+
end_dt = datetime.strptime(before[:19], fmt)
|
|
716
|
+
|
|
717
|
+
windows = []
|
|
718
|
+
cursor = start_dt
|
|
719
|
+
while cursor <= end_dt:
|
|
720
|
+
# Each window spans 7 days; window end is cursor + 6 days at 23:59:59
|
|
721
|
+
window_end = (cursor + timedelta(days=6)).replace(hour=23, minute=59, second=59)
|
|
722
|
+
if window_end > end_dt:
|
|
723
|
+
window_end = end_dt
|
|
724
|
+
windows.append((
|
|
725
|
+
cursor.strftime(fmt) + tz_off,
|
|
726
|
+
window_end.strftime(fmt) + tz_off,
|
|
727
|
+
))
|
|
728
|
+
cursor = (cursor + timedelta(days=7)).replace(hour=0, minute=0, second=0)
|
|
729
|
+
return windows
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _split_week_to_days(w_after: str, w_before: str) -> List[tuple]:
|
|
733
|
+
"""Split a weekly window into daily sub-windows (for adaptive degradation)."""
|
|
734
|
+
from datetime import datetime, timedelta
|
|
735
|
+
fmt = "%Y-%m-%dT%H:%M:%S"
|
|
736
|
+
tz_off = config.DEFAULT_TZ_OFFSET
|
|
737
|
+
start_dt = datetime.strptime(w_after[:19], fmt)
|
|
738
|
+
end_dt = datetime.strptime(w_before[:19], fmt)
|
|
739
|
+
|
|
740
|
+
days = []
|
|
741
|
+
cursor = start_dt
|
|
742
|
+
while cursor <= end_dt:
|
|
743
|
+
day_end = cursor.replace(hour=23, minute=59, second=59)
|
|
744
|
+
if day_end > end_dt:
|
|
745
|
+
day_end = end_dt
|
|
746
|
+
days.append((
|
|
747
|
+
cursor.strftime(fmt) + tz_off,
|
|
748
|
+
day_end.strftime(fmt) + tz_off,
|
|
749
|
+
))
|
|
750
|
+
cursor = (cursor + timedelta(days=1)).replace(hour=0, minute=0, second=0)
|
|
751
|
+
return days
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _fetch_orders_auto_split(
|
|
755
|
+
client: "MiraviaClient",
|
|
756
|
+
after: str,
|
|
757
|
+
before: str,
|
|
758
|
+
api_status: Optional[str],
|
|
759
|
+
max_orders: int,
|
|
760
|
+
channel: Optional[str] = None,
|
|
761
|
+
ship_to: Optional[str] = None,
|
|
762
|
+
order_numbers: Optional[str] = None,
|
|
763
|
+
) -> List[Dict[str, Any]]:
|
|
764
|
+
"""Fetch orders with automatic time-window splitting (supports concurrency).
|
|
765
|
+
|
|
766
|
+
Strategy:
|
|
767
|
+
1. First attempt to fetch within the full time range (limit: ORDERS_PER_WINDOW).
|
|
768
|
+
2. If full window data exceeds ORDERS_PER_WINDOW, split by day.
|
|
769
|
+
3. When total > CONCURRENT_THRESHOLD, enable thread pool concurrency.
|
|
770
|
+
4. Stop when total reaches max_orders.
|
|
771
|
+
"""
|
|
772
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
773
|
+
|
|
774
|
+
per_window = config.ORDERS_PER_WINDOW # 5000 (API returns empty at offset>=5000)
|
|
775
|
+
|
|
776
|
+
# Probe total count (first page gives countTotal; internal use only for split decision)
|
|
777
|
+
first_page = client.get_orders_page(
|
|
778
|
+
created_after=after, created_before=before,
|
|
779
|
+
status=api_status, limit=1, offset=0,
|
|
780
|
+
channel=channel, ship_to=ship_to, order_numbers=order_numbers,
|
|
781
|
+
)
|
|
782
|
+
total_in_range = int(first_page.get("countTotal", 0))
|
|
783
|
+
# NOTE: countTotal is an API estimate; may differ from actual fetchable count; used only for internal split decision
|
|
784
|
+
print("progress: fetching orders...", flush=True)
|
|
785
|
+
|
|
786
|
+
if total_in_range == 0:
|
|
787
|
+
return []
|
|
788
|
+
|
|
789
|
+
# Single window sufficient: fetch directly
|
|
790
|
+
if total_in_range <= per_window:
|
|
791
|
+
orders = list(client.iter_orders(
|
|
792
|
+
created_after=after, created_before=before,
|
|
793
|
+
status=api_status, max_orders=min(total_in_range, max_orders),
|
|
794
|
+
quiet=False, channel=channel, ship_to=ship_to, order_numbers=order_numbers,
|
|
795
|
+
))
|
|
796
|
+
return orders
|
|
797
|
+
|
|
798
|
+
# Need splitting
|
|
799
|
+
windows = _split_time_range(after, before)
|
|
800
|
+
use_concurrent = (total_in_range >= config.CONCURRENT_THRESHOLD
|
|
801
|
+
and len(windows) > 10)
|
|
802
|
+
workers = config.CONCURRENT_WORKERS if use_concurrent else 1
|
|
803
|
+
mode_label = f"concurrent({workers} workers)" if use_concurrent else "sequential"
|
|
804
|
+
print(
|
|
805
|
+
f"progress: splitting into {len(windows)} time windows"
|
|
806
|
+
f" ({per_window} orders per window max, {mode_label})",
|
|
807
|
+
flush=True,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
all_orders: List[Dict[str, Any]] = []
|
|
811
|
+
seen_ids: set = set()
|
|
812
|
+
|
|
813
|
+
def _fetch_window(w_after: str, w_before: str) -> List[Dict[str, Any]]:
|
|
814
|
+
"""Worker: fetch orders for a single window.
|
|
815
|
+
|
|
816
|
+
Adaptive degradation: probes window order count; if it exceeds
|
|
817
|
+
ADAPTIVE_DEGRADE_THRESHOLD, automatically splits the week into daily sub-windows.
|
|
818
|
+
"""
|
|
819
|
+
# Probe window order count
|
|
820
|
+
probe = client.get_orders_page(
|
|
821
|
+
created_after=w_after, created_before=w_before,
|
|
822
|
+
status=api_status, limit=1, offset=0,
|
|
823
|
+
channel=channel, ship_to=ship_to, order_numbers=order_numbers,
|
|
824
|
+
)
|
|
825
|
+
window_total = int(probe.get("countTotal", 0))
|
|
826
|
+
|
|
827
|
+
# Adaptive degradation: split to days if threshold exceeded
|
|
828
|
+
if window_total > config.ADAPTIVE_DEGRADE_THRESHOLD:
|
|
829
|
+
logger.info("adaptive degrade: window %s~%s has %d orders (>%d), splitting to days",
|
|
830
|
+
w_after[:10], w_before[:10], window_total, config.ADAPTIVE_DEGRADE_THRESHOLD)
|
|
831
|
+
day_windows = _split_week_to_days(w_after, w_before)
|
|
832
|
+
results = []
|
|
833
|
+
for d_after, d_before in day_windows:
|
|
834
|
+
day_orders = list(client.iter_orders(
|
|
835
|
+
created_after=d_after, created_before=d_before,
|
|
836
|
+
status=api_status, max_orders=per_window,
|
|
837
|
+
quiet=True, channel=channel, ship_to=ship_to, order_numbers=order_numbers,
|
|
838
|
+
))
|
|
839
|
+
results.extend(day_orders)
|
|
840
|
+
return results
|
|
841
|
+
|
|
842
|
+
# Normal fetch
|
|
843
|
+
if window_total == 0:
|
|
844
|
+
return []
|
|
845
|
+
return list(client.iter_orders(
|
|
846
|
+
created_after=w_after, created_before=w_before,
|
|
847
|
+
status=api_status, max_orders=per_window,
|
|
848
|
+
quiet=True, channel=channel, ship_to=ship_to, order_numbers=order_numbers,
|
|
849
|
+
))
|
|
850
|
+
|
|
851
|
+
if not use_concurrent:
|
|
852
|
+
# Sequential mode (small order volume, preserves original behavior)
|
|
853
|
+
for idx, (w_after, w_before) in enumerate(windows, 1):
|
|
854
|
+
if len(all_orders) >= max_orders:
|
|
855
|
+
break
|
|
856
|
+
batch = _fetch_window(w_after, w_before)
|
|
857
|
+
for o in batch:
|
|
858
|
+
oid = str(o.get("order_id"))
|
|
859
|
+
if oid not in seen_ids:
|
|
860
|
+
seen_ids.add(oid)
|
|
861
|
+
all_orders.append(o)
|
|
862
|
+
if len(all_orders) >= max_orders:
|
|
863
|
+
break
|
|
864
|
+
print(
|
|
865
|
+
f"progress: fetched {len(all_orders)} orders"
|
|
866
|
+
f" (window {idx}/{len(windows)})",
|
|
867
|
+
flush=True,
|
|
868
|
+
)
|
|
869
|
+
else:
|
|
870
|
+
# Concurrent mode: use thread pool for batch fetching
|
|
871
|
+
completed = 0
|
|
872
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
873
|
+
futures = {
|
|
874
|
+
pool.submit(_fetch_window, w_after, w_before): idx
|
|
875
|
+
for idx, (w_after, w_before) in enumerate(windows, 1)
|
|
876
|
+
}
|
|
877
|
+
for future in as_completed(futures):
|
|
878
|
+
completed += 1
|
|
879
|
+
try:
|
|
880
|
+
batch = future.result()
|
|
881
|
+
except Exception as e:
|
|
882
|
+
logger.warning("window %d failed: %s", futures[future], e)
|
|
883
|
+
continue
|
|
884
|
+
for o in batch:
|
|
885
|
+
oid = str(o.get("order_id"))
|
|
886
|
+
if oid not in seen_ids:
|
|
887
|
+
seen_ids.add(oid)
|
|
888
|
+
all_orders.append(o)
|
|
889
|
+
# Print progress every 5 windows or on last window
|
|
890
|
+
if completed % 5 == 0 or completed == len(futures):
|
|
891
|
+
print(
|
|
892
|
+
f"progress: fetched {len(all_orders)} orders"
|
|
893
|
+
f" ({completed}/{len(windows)} windows done)",
|
|
894
|
+
flush=True,
|
|
895
|
+
)
|
|
896
|
+
# Truncate in concurrent mode
|
|
897
|
+
if len(all_orders) > max_orders:
|
|
898
|
+
all_orders = all_orders[:max_orders]
|
|
899
|
+
|
|
900
|
+
return all_orders
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
# -------- reverse order time-window auto-splitting --------
|
|
904
|
+
|
|
905
|
+
def _split_reverse_time_range_monthly(after_ms: int, before_ms: int) -> List[tuple]:
|
|
906
|
+
"""Split millisecond timestamp range into monthly (start_ms, end_ms) tuples.
|
|
907
|
+
|
|
908
|
+
Each window aligns to natural month boundaries (whole-day aligned), ensuring
|
|
909
|
+
reverse order count per window doesn't exceed server deep-page limit
|
|
910
|
+
(max_pages=30 x page_size=100 = 3000).
|
|
911
|
+
"""
|
|
912
|
+
from datetime import datetime, timedelta, timezone
|
|
913
|
+
tz_off = config.DEFAULT_TZ_OFFSET or "+02:00"
|
|
914
|
+
sign = 1 if tz_off[0] == "+" else -1
|
|
915
|
+
hh, mm = tz_off[1:].split(":")
|
|
916
|
+
tz = timezone(sign * timedelta(hours=int(hh), minutes=int(mm)))
|
|
917
|
+
|
|
918
|
+
start_dt = datetime.fromtimestamp(after_ms / 1000, tz=tz)
|
|
919
|
+
end_dt = datetime.fromtimestamp(before_ms / 1000, tz=tz)
|
|
920
|
+
|
|
921
|
+
windows = []
|
|
922
|
+
cursor = start_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
923
|
+
while cursor <= end_dt:
|
|
924
|
+
# Month end: next month 1st - 1 second
|
|
925
|
+
if cursor.month == 12:
|
|
926
|
+
next_month = cursor.replace(year=cursor.year + 1, month=1)
|
|
927
|
+
else:
|
|
928
|
+
next_month = cursor.replace(month=cursor.month + 1)
|
|
929
|
+
month_end = next_month - timedelta(seconds=1)
|
|
930
|
+
|
|
931
|
+
# Clip to actual query range
|
|
932
|
+
w_start = max(cursor, start_dt)
|
|
933
|
+
w_end = min(month_end, end_dt)
|
|
934
|
+
|
|
935
|
+
w_start_ms = int(w_start.timestamp() * 1000)
|
|
936
|
+
w_end_ms = int(w_end.timestamp() * 1000)
|
|
937
|
+
if w_start_ms <= w_end_ms:
|
|
938
|
+
windows.append((w_start_ms, w_end_ms))
|
|
939
|
+
|
|
940
|
+
cursor = next_month
|
|
941
|
+
return windows
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _fetch_reverse_auto_split(
|
|
945
|
+
client: "MiraviaClient",
|
|
946
|
+
after_ms: int,
|
|
947
|
+
before_ms: int,
|
|
948
|
+
) -> List[Dict[str, Any]]:
|
|
949
|
+
"""Fetch reverse orders with automatic monthly splitting to avoid server deep-page limit (supports concurrency).
|
|
950
|
+
|
|
951
|
+
Strategy:
|
|
952
|
+
1. If time range <= 30 days, fetch directly (single month unlikely to exceed 3000 records).
|
|
953
|
+
2. If time range > 30 days, split by natural month, fetch concurrently, deduplicate across windows.
|
|
954
|
+
|
|
955
|
+
Compared to previous single full-range fetch, this function ensures no reverse order records are lost.
|
|
956
|
+
"""
|
|
957
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
958
|
+
|
|
959
|
+
days = (before_ms - after_ms) / (1000 * 86400)
|
|
960
|
+
|
|
961
|
+
# Short time range: fetch directly, but check for potential truncation
|
|
962
|
+
if days <= 30:
|
|
963
|
+
results = client.get_reverse_orders(
|
|
964
|
+
created_after_ms=after_ms,
|
|
965
|
+
created_before_ms=before_ms,
|
|
966
|
+
)
|
|
967
|
+
# If we got near the max_pages * page_size cap (3000), data may have been
|
|
968
|
+
# silently truncated. Fall back to weekly splitting to ensure completeness.
|
|
969
|
+
max_expected = 30 * 100 # default max_pages * page_size
|
|
970
|
+
if len(results) >= max_expected * 0.95:
|
|
971
|
+
logger.warning(
|
|
972
|
+
"reverse short-range may have truncated data (%d records, near cap %d), "
|
|
973
|
+
"falling back to weekly splitting",
|
|
974
|
+
len(results), max_expected,
|
|
975
|
+
)
|
|
976
|
+
from datetime import datetime, timedelta
|
|
977
|
+
tz = _tz()
|
|
978
|
+
start_dt = datetime.fromtimestamp(after_ms / 1000, tz=tz)
|
|
979
|
+
end_dt = datetime.fromtimestamp(before_ms / 1000, tz=tz)
|
|
980
|
+
all_results: List[Dict[str, Any]] = []
|
|
981
|
+
seen_ids: set = set()
|
|
982
|
+
cursor = start_dt
|
|
983
|
+
while cursor <= end_dt:
|
|
984
|
+
w_end = min(cursor + timedelta(days=7), end_dt)
|
|
985
|
+
w_after_ms = int(cursor.timestamp() * 1000)
|
|
986
|
+
w_before_ms = int(w_end.timestamp() * 1000)
|
|
987
|
+
try:
|
|
988
|
+
batch = client.get_reverse_orders(
|
|
989
|
+
created_after_ms=w_after_ms,
|
|
990
|
+
created_before_ms=w_before_ms,
|
|
991
|
+
)
|
|
992
|
+
except (MiraviaError, ValueError) as e:
|
|
993
|
+
logger.warning("reverse weekly window failed: %s", e)
|
|
994
|
+
batch = []
|
|
995
|
+
for r in batch:
|
|
996
|
+
rid = (r.get("reverse_order_id") or r.get("dispute_id") or id(r))
|
|
997
|
+
if rid not in seen_ids:
|
|
998
|
+
seen_ids.add(rid)
|
|
999
|
+
all_results.append(r)
|
|
1000
|
+
cursor = w_end + timedelta(seconds=1)
|
|
1001
|
+
return all_results
|
|
1002
|
+
return results
|
|
1003
|
+
|
|
1004
|
+
# Long time range: split by month
|
|
1005
|
+
windows = _split_reverse_time_range_monthly(after_ms, before_ms)
|
|
1006
|
+
use_concurrent = len(windows) > 3
|
|
1007
|
+
workers = config.CONCURRENT_WORKERS if use_concurrent else 1
|
|
1008
|
+
logger.info("reverse auto-split: %d monthly windows for %.0f-day range (%s)",
|
|
1009
|
+
len(windows), days,
|
|
1010
|
+
f"concurrent({workers})" if use_concurrent else "sequential")
|
|
1011
|
+
|
|
1012
|
+
all_results: List[Dict[str, Any]] = []
|
|
1013
|
+
seen_ids: set = set()
|
|
1014
|
+
|
|
1015
|
+
def _fetch_month(w_after: int, w_before: int) -> List[Dict[str, Any]]:
|
|
1016
|
+
return client.get_reverse_orders(
|
|
1017
|
+
created_after_ms=w_after,
|
|
1018
|
+
created_before_ms=w_before,
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
if not use_concurrent:
|
|
1022
|
+
for idx, (w_after, w_before) in enumerate(windows, 1):
|
|
1023
|
+
try:
|
|
1024
|
+
batch = _fetch_month(w_after, w_before)
|
|
1025
|
+
except (MiraviaError, ValueError) as e:
|
|
1026
|
+
logger.warning("reverse window %d/%d failed: %s", idx, len(windows), e)
|
|
1027
|
+
continue
|
|
1028
|
+
for r in batch:
|
|
1029
|
+
rid = (r.get("reverse_order_id") or r.get("dispute_id") or id(r))
|
|
1030
|
+
if rid not in seen_ids:
|
|
1031
|
+
seen_ids.add(rid)
|
|
1032
|
+
all_results.append(r)
|
|
1033
|
+
else:
|
|
1034
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
1035
|
+
futures = {
|
|
1036
|
+
pool.submit(_fetch_month, w_after, w_before): idx
|
|
1037
|
+
for idx, (w_after, w_before) in enumerate(windows, 1)
|
|
1038
|
+
}
|
|
1039
|
+
for future in as_completed(futures):
|
|
1040
|
+
try:
|
|
1041
|
+
batch = future.result()
|
|
1042
|
+
except (MiraviaError, ValueError, Exception) as e:
|
|
1043
|
+
logger.warning("reverse window %d/%d failed: %s",
|
|
1044
|
+
futures[future], len(windows), e)
|
|
1045
|
+
continue
|
|
1046
|
+
for r in batch:
|
|
1047
|
+
rid = (r.get("reverse_order_id") or r.get("dispute_id") or id(r))
|
|
1048
|
+
if rid not in seen_ids:
|
|
1049
|
+
seen_ids.add(rid)
|
|
1050
|
+
all_results.append(r)
|
|
1051
|
+
|
|
1052
|
+
logger.info("reverse auto-split done: %d records from %d windows",
|
|
1053
|
+
len(all_results), len(windows))
|
|
1054
|
+
return all_results
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
# -------- token pre-check --------
|
|
1058
|
+
|
|
1059
|
+
def _check_token_expiry() -> Optional[str]:
|
|
1060
|
+
"""Check if token_expires_at in credentials.json has expired.
|
|
1061
|
+
|
|
1062
|
+
Returns None if token is valid (or cannot be determined), returns error message if expired.
|
|
1063
|
+
"""
|
|
1064
|
+
if not os.path.exists(config.CRED_FILE):
|
|
1065
|
+
return None
|
|
1066
|
+
try:
|
|
1067
|
+
with open(config.CRED_FILE, "r", encoding="utf-8") as f:
|
|
1068
|
+
data = json.load(f)
|
|
1069
|
+
expires_str = data.get("token_expires_at", "")
|
|
1070
|
+
if not expires_str:
|
|
1071
|
+
return None
|
|
1072
|
+
# Supports both "2026-06-24 18:27" and "2026-06-24T18:27:00" formats
|
|
1073
|
+
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
|
|
1074
|
+
try:
|
|
1075
|
+
expires_dt = datetime.strptime(expires_str, fmt)
|
|
1076
|
+
break
|
|
1077
|
+
except ValueError:
|
|
1078
|
+
continue
|
|
1079
|
+
else:
|
|
1080
|
+
return None # Cannot parse, skip check
|
|
1081
|
+
now = datetime.now()
|
|
1082
|
+
if now > expires_dt:
|
|
1083
|
+
return (
|
|
1084
|
+
f"Token expired (expires_at={expires_str}, now={now.strftime('%Y-%m-%d %H:%M:%S')}).\n"
|
|
1085
|
+
f"Please re-authorize: python scripts/auth_cli.py login"
|
|
1086
|
+
)
|
|
1087
|
+
# Near-expiry warning (less than 2 minutes remaining)
|
|
1088
|
+
remaining = (expires_dt - now).total_seconds()
|
|
1089
|
+
if remaining < 120:
|
|
1090
|
+
print(
|
|
1091
|
+
f"warning: token will expire in {int(remaining)}s, consider re-authorizing soon.",
|
|
1092
|
+
file=sys.stderr,
|
|
1093
|
+
)
|
|
1094
|
+
return None
|
|
1095
|
+
except (OSError, json.JSONDecodeError, Exception):
|
|
1096
|
+
return None # Read failure should not block the flow
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _preflight_token_check(client: MiraviaClient) -> Optional[str]:
|
|
1100
|
+
"""Pre-flight check: first check local expiry time, then verify token validity via ping.
|
|
1101
|
+
|
|
1102
|
+
Returns None if token is valid, returns error message if token is invalid.
|
|
1103
|
+
"""
|
|
1104
|
+
# 1. Local quick check: token_expires_at
|
|
1105
|
+
expiry_err = _check_token_expiry()
|
|
1106
|
+
if expiry_err:
|
|
1107
|
+
return expiry_err
|
|
1108
|
+
|
|
1109
|
+
# 2. Remote verification: lightweight ping
|
|
1110
|
+
try:
|
|
1111
|
+
client.ping()
|
|
1112
|
+
return None
|
|
1113
|
+
except MiraviaError as e:
|
|
1114
|
+
if "IllegalAccessToken" in str(e.code) or "AccessTokenExpired" in str(e.code):
|
|
1115
|
+
return (
|
|
1116
|
+
f"Token verification failed (API returned {e.code}): {e.msg}\n"
|
|
1117
|
+
f"Please re-authorize: python scripts/auth_cli.py login"
|
|
1118
|
+
)
|
|
1119
|
+
# Other error types (e.g., network issues) should not block
|
|
1120
|
+
logger.warning("preflight ping failed with non-auth error: %s", e)
|
|
1121
|
+
return None
|
|
1122
|
+
except Exception as e:
|
|
1123
|
+
logger.warning("preflight ping unexpected error: %s", e)
|
|
1124
|
+
return None
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
# -------- main flow --------
|
|
1128
|
+
|
|
1129
|
+
def run(args: argparse.Namespace) -> int:
|
|
1130
|
+
# Reset API call tracker, start new timing round
|
|
1131
|
+
api_tracker.reset()
|
|
1132
|
+
_start_ms = int(time.time() * 1000)
|
|
1133
|
+
# Determine action: ping / inspect / scenario(arg) / auto
|
|
1134
|
+
_action = "ping" if args.ping else (args.scenario or "auto")
|
|
1135
|
+
client = MiraviaClient(action=_action)
|
|
1136
|
+
|
|
1137
|
+
if args.ping:
|
|
1138
|
+
try:
|
|
1139
|
+
client.ping()
|
|
1140
|
+
print("ping OK")
|
|
1141
|
+
_duration = int(time.time() * 1000) - _start_ms
|
|
1142
|
+
write_skill_log(action="ping", status="success", duration_ms=_duration, data_count=0)
|
|
1143
|
+
return 0
|
|
1144
|
+
except MiraviaError as e:
|
|
1145
|
+
print(f"ping FAIL: {e}", file=sys.stderr)
|
|
1146
|
+
_duration = int(time.time() * 1000) - _start_ms
|
|
1147
|
+
write_skill_log(action="ping", status="fail", duration_ms=_duration, error_code=e.code)
|
|
1148
|
+
return 1
|
|
1149
|
+
|
|
1150
|
+
# \u2605 Pre-flight token validation: confirm token is valid before any data fetching
|
|
1151
|
+
# (including inspect scenario, so expired token gives friendly re-auth prompt)
|
|
1152
|
+
token_err = _preflight_token_check(client)
|
|
1153
|
+
if token_err:
|
|
1154
|
+
print(f"error: {token_err}", file=sys.stderr)
|
|
1155
|
+
_duration = int(time.time() * 1000) - _start_ms
|
|
1156
|
+
write_skill_log(
|
|
1157
|
+
action=(_action if _action != "ping" else "auto"),
|
|
1158
|
+
status="fail", duration_ms=_duration,
|
|
1159
|
+
error_code="TOKEN_EXPIRED",
|
|
1160
|
+
remark="pre-flight token validation failed")
|
|
1161
|
+
return 1
|
|
1162
|
+
|
|
1163
|
+
# inspect scenario uses single-order direct query path
|
|
1164
|
+
if (args.scenario or "").lower() == "inspect":
|
|
1165
|
+
rc = run_inspect(args, client)
|
|
1166
|
+
_duration = int(time.time() * 1000) - _start_ms
|
|
1167
|
+
write_skill_log(
|
|
1168
|
+
action="inspect", status="success" if rc == 0 else "fail",
|
|
1169
|
+
duration_ms=_duration, data_count=0,
|
|
1170
|
+
remark=f"order_ids={args.order_ids or ''}")
|
|
1171
|
+
return rc
|
|
1172
|
+
|
|
1173
|
+
if args.last_days is not None:
|
|
1174
|
+
after, before = _last_days_range(args.last_days)
|
|
1175
|
+
elif args.created_after and args.created_before:
|
|
1176
|
+
after = _parse_dt(args.created_after)
|
|
1177
|
+
before = _parse_dt(args.created_before, end_of_day=True)
|
|
1178
|
+
else:
|
|
1179
|
+
print(
|
|
1180
|
+
"error: time range required. Use --last-days N or --created-after/--created-before.\n"
|
|
1181
|
+
" (Core Rule #2: default time range is prohibited)",
|
|
1182
|
+
file=sys.stderr,
|
|
1183
|
+
)
|
|
1184
|
+
return 1
|
|
1185
|
+
print(f"range : {after} ~ {before}")
|
|
1186
|
+
|
|
1187
|
+
api_status = _resolve_api_status(args.status)
|
|
1188
|
+
api_channel = getattr(args, 'channel', None)
|
|
1189
|
+
api_ship_to = getattr(args, 'ship_to', None)
|
|
1190
|
+
api_order_numbers = getattr(args, 'order_numbers', None)
|
|
1191
|
+
|
|
1192
|
+
# 1. Fetch orders (auto time-window splitting)
|
|
1193
|
+
orders = _fetch_orders_auto_split(
|
|
1194
|
+
client, after, before,
|
|
1195
|
+
api_status=api_status,
|
|
1196
|
+
max_orders=args.max_orders,
|
|
1197
|
+
channel=api_channel,
|
|
1198
|
+
ship_to=api_ship_to,
|
|
1199
|
+
order_numbers=api_order_numbers,
|
|
1200
|
+
)
|
|
1201
|
+
if not orders:
|
|
1202
|
+
# ★ Distinguish "truly no data" from "empty result due to API failures"
|
|
1203
|
+
total_calls = api_tracker.call_count
|
|
1204
|
+
success_calls = api_tracker.success_count
|
|
1205
|
+
if total_calls > 1 and success_calls <= 1:
|
|
1206
|
+
# Mass API call failures: likely token expired or network issue, should not generate empty report
|
|
1207
|
+
print(
|
|
1208
|
+
f"error: API calls mostly failed ({success_calls}/{total_calls} succeeded), "
|
|
1209
|
+
f"likely token expired or network error. Aborting report generation.\n"
|
|
1210
|
+
f"Please re-authorize: python scripts/auth_cli.py login",
|
|
1211
|
+
file=sys.stderr,
|
|
1212
|
+
)
|
|
1213
|
+
_duration = int(time.time() * 1000) - _start_ms
|
|
1214
|
+
write_skill_log(
|
|
1215
|
+
action=(args.scenario or "auto"), status="fail",
|
|
1216
|
+
duration_ms=_duration, data_count=0,
|
|
1217
|
+
error_code="API_MASS_FAILURE",
|
|
1218
|
+
remark=f"range={after}~{before} api_success={success_calls}/{total_calls}")
|
|
1219
|
+
return 1
|
|
1220
|
+
# ★ Truly no data: stop early, do NOT generate empty Excel/HTML
|
|
1221
|
+
print(
|
|
1222
|
+
f"no_data : No orders found for the specified conditions "
|
|
1223
|
+
f"(range={after} ~ {before}, status={args.status or 'all'}, "
|
|
1224
|
+
f"channel={api_channel or 'all'}, ship_to={api_ship_to or 'all'}).",
|
|
1225
|
+
flush=True,
|
|
1226
|
+
)
|
|
1227
|
+
_duration = int(time.time() * 1000) - _start_ms
|
|
1228
|
+
write_skill_log(
|
|
1229
|
+
action=(args.scenario or "auto"), status="no_data",
|
|
1230
|
+
duration_ms=_duration, data_count=0,
|
|
1231
|
+
remark=f"range={after}~{before} status={args.status} channel={api_channel} ship_to={api_ship_to}")
|
|
1232
|
+
return 3 # Exit code 3 = no data found (distinct from error=1 and cap=2)
|
|
1233
|
+
|
|
1234
|
+
# 2. Fetch order items (enable concurrency when order count > CONCURRENT_THRESHOLD)
|
|
1235
|
+
print("progress: fetching order items...", flush=True)
|
|
1236
|
+
order_ids = [str(o.get("order_id")) for o in orders if o.get("order_id")]
|
|
1237
|
+
use_items_concurrent = len(order_ids) > config.CONCURRENT_THRESHOLD
|
|
1238
|
+
items_resp = client.get_multi_order_items(order_ids, concurrent=use_items_concurrent)
|
|
1239
|
+
items_by_order: Dict[str, Dict[str, Any]] = {}
|
|
1240
|
+
for entry in items_resp:
|
|
1241
|
+
oid = str(entry.get("order_id"))
|
|
1242
|
+
items_by_order[oid] = {
|
|
1243
|
+
"buyer_remark": entry.get("buyer_remark_text") or "",
|
|
1244
|
+
"seller_remark": entry.get("seller_remark_text") or "",
|
|
1245
|
+
"items": entry.get("order_items") if entry.get("order_items") is not None else (entry.get("items") if entry.get("items") is not None else []),
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
# 3. Fetch reverse orders (auto monthly splitting to avoid deep-page data loss)
|
|
1249
|
+
print("progress: fetching refund/return data...", flush=True)
|
|
1250
|
+
after_ms = _iso_to_ms(after)
|
|
1251
|
+
before_ms = _iso_to_ms(before)
|
|
1252
|
+
reverse_list = _fetch_reverse_auto_split(
|
|
1253
|
+
client,
|
|
1254
|
+
after_ms=after_ms,
|
|
1255
|
+
before_ms=before_ms,
|
|
1256
|
+
)
|
|
1257
|
+
reverse_by_item: Dict[str, List[Dict[str, Any]]] = {}
|
|
1258
|
+
for r in reverse_list:
|
|
1259
|
+
rev_id = r.get("reverse_order_id") or r.get("dispute_id")
|
|
1260
|
+
lines = r.get("reverse_order_lines") or []
|
|
1261
|
+
if lines:
|
|
1262
|
+
for line in lines:
|
|
1263
|
+
oil = (line.get("trade_order_line_id")
|
|
1264
|
+
or line.get("marketplace_trade_order_line_id")
|
|
1265
|
+
or line.get("reverse_order_line_id"))
|
|
1266
|
+
if not oil:
|
|
1267
|
+
continue
|
|
1268
|
+
rev_entry = {
|
|
1269
|
+
"reverse_order_id": rev_id,
|
|
1270
|
+
"apply_time": line.get("return_order_line_gmt_create")
|
|
1271
|
+
or r.get("apply_time"),
|
|
1272
|
+
"create_time": line.get("trade_order_gmt_create")
|
|
1273
|
+
or r.get("create_time"),
|
|
1274
|
+
"status": line.get("reverse_status") or r.get("status"),
|
|
1275
|
+
"refund_amount": line.get("refund_amount"),
|
|
1276
|
+
"reason": line.get("reason_text") or r.get("reason"),
|
|
1277
|
+
"fast_refund": line.get("is_fast_refund"),
|
|
1278
|
+
"instant_refund": line.get("is_instant_refund"),
|
|
1279
|
+
"return_tracking_no": line.get("return_tracking_no")
|
|
1280
|
+
or r.get("return_tracking_no"),
|
|
1281
|
+
# v4 addition: pass through from order/item level
|
|
1282
|
+
"request_type": r.get("request_type"), # \u2605 order-level pass-through
|
|
1283
|
+
"duty_party": line.get("duty_party") or line.get("dutyParty"),
|
|
1284
|
+
"is_not_received": line.get("is_not_received") or line.get("isNotReceived"),
|
|
1285
|
+
"refund_payment_method": line.get("refund_payment_method") or line.get("refundPaymentMethod"),
|
|
1286
|
+
"reason_code": line.get("reason_code") or line.get("reasonCode"),
|
|
1287
|
+
"ofc_status": line.get("ofc_status") or line.get("ofcStatus"),
|
|
1288
|
+
"refund_currency": line.get("refund_currency") or line.get("refundCurrency"),
|
|
1289
|
+
"seller_sku_id": line.get("seller_sku_id") or line.get("sellerSkuId"),
|
|
1290
|
+
}
|
|
1291
|
+
reverse_by_item.setdefault(str(oil), []).append(rev_entry)
|
|
1292
|
+
else:
|
|
1293
|
+
for k in ("order_item_id", "trade_order_line_id", "sub_order_id"):
|
|
1294
|
+
v = r.get(k)
|
|
1295
|
+
if v:
|
|
1296
|
+
reverse_by_item.setdefault(str(v), []).append(
|
|
1297
|
+
{**r, "reverse_order_id": rev_id}
|
|
1298
|
+
)
|
|
1299
|
+
break
|
|
1300
|
+
|
|
1301
|
+
# 4. Aggregate
|
|
1302
|
+
rows = _flatten(orders, items_by_order, reverse_by_item)
|
|
1303
|
+
main_order_count = len(set(str(o.get("order_id")) for o in orders if o.get("order_id")))
|
|
1304
|
+
total_sub_orders = len(rows)
|
|
1305
|
+
print(f"progress: {main_order_count} main orders, {total_sub_orders} sub-order rows total")
|
|
1306
|
+
|
|
1307
|
+
# Check if exceeding limit
|
|
1308
|
+
hit_cap = total_sub_orders >= args.max_orders
|
|
1309
|
+
if hit_cap:
|
|
1310
|
+
# Truncate to limit
|
|
1311
|
+
rows = rows[:args.max_orders]
|
|
1312
|
+
print(
|
|
1313
|
+
f"warning: data exceeds export limit ({args.max_orders} sub-orders). "
|
|
1314
|
+
f"Exported first {args.max_orders} records. "
|
|
1315
|
+
f"Please narrow the time range and retry for complete data.",
|
|
1316
|
+
file=sys.stderr,
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
# 5. Determine outputs by scenario
|
|
1320
|
+
out_dir = Path(args.output).expanduser().resolve()
|
|
1321
|
+
fname_range = f"{_safe_filename(after)}_{_safe_filename(before)}"
|
|
1322
|
+
ts = time.strftime("%Y%m%d%H%M%S")
|
|
1323
|
+
range_label = f"{after} ~ {before}"
|
|
1324
|
+
|
|
1325
|
+
scenario, do_excel, do_report = _resolve_outputs(args)
|
|
1326
|
+
print(f"scenario : {scenario} (excel={do_excel}, report={'v4' if do_report else 'none'})")
|
|
1327
|
+
|
|
1328
|
+
if do_excel:
|
|
1329
|
+
excel_path = out_dir / (
|
|
1330
|
+
f"miravia_orders_{fname_range}_{ts}." + ('csv' if args.format == 'csv' else 'xlsx')
|
|
1331
|
+
)
|
|
1332
|
+
excel_path = _resolve_collision(excel_path)
|
|
1333
|
+
export_dataframe(rows, excel_path, args.format)
|
|
1334
|
+
print(f"output : {excel_path}")
|
|
1335
|
+
|
|
1336
|
+
if do_report:
|
|
1337
|
+
html_path = out_dir / f"miravia_order_report_{fname_range}_{ts}.html"
|
|
1338
|
+
html_path = _resolve_collision(html_path)
|
|
1339
|
+
render_v4_report(rows, html_path, range_label=range_label, lang=getattr(args, 'lang', 'en'))
|
|
1340
|
+
print(f"report : {html_path}")
|
|
1341
|
+
|
|
1342
|
+
# Export structured metrics for Agent LLM analysis
|
|
1343
|
+
import report_v4
|
|
1344
|
+
metrics_data = report_v4.export_metrics(
|
|
1345
|
+
rows=rows,
|
|
1346
|
+
export_columns=EXPORT_COLUMNS,
|
|
1347
|
+
range_start=str(after),
|
|
1348
|
+
range_end=str(before),
|
|
1349
|
+
)
|
|
1350
|
+
metrics_path = out_dir / f"metrics_{fname_range}_{ts}.json"
|
|
1351
|
+
metrics_path = _resolve_collision(metrics_path)
|
|
1352
|
+
metrics_path.write_text(
|
|
1353
|
+
json.dumps(metrics_data, ensure_ascii=False, indent=2, default=str),
|
|
1354
|
+
encoding="utf-8",
|
|
1355
|
+
)
|
|
1356
|
+
print(f"metrics : {metrics_path}")
|
|
1357
|
+
|
|
1358
|
+
if getattr(args, "open_browser", True):
|
|
1359
|
+
_open_in_browser(html_path)
|
|
1360
|
+
|
|
1361
|
+
# Write skill invocation log
|
|
1362
|
+
_write_run_log(
|
|
1363
|
+
start_ms=_start_ms, scenario=scenario,
|
|
1364
|
+
data_count=total_sub_orders,
|
|
1365
|
+
remark=f"range={after}~{before} hit_cap={hit_cap}",
|
|
1366
|
+
)
|
|
1367
|
+
return 2 if hit_cap else 0
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _write_run_log(start_ms: int, scenario: str, data_count: int,
|
|
1371
|
+
error_code: str = "", remark: str = "") -> None:
|
|
1372
|
+
"""Write skill log after run() completes normally."""
|
|
1373
|
+
_duration = int(time.time() * 1000) - start_ms
|
|
1374
|
+
status = "success" if not error_code else "fail"
|
|
1375
|
+
write_skill_log(
|
|
1376
|
+
action=scenario, status=status,
|
|
1377
|
+
duration_ms=_duration, data_count=data_count,
|
|
1378
|
+
error_code=error_code, remark=remark,
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _open_in_browser(html_path: Path) -> None:
|
|
1383
|
+
"""Open HTML report in user's default browser (cross-platform with fallback).
|
|
1384
|
+
|
|
1385
|
+
Prefers webbrowser module; falls back to platform commands (macOS: `open`, Linux: `xdg-open`,
|
|
1386
|
+
Windows: `os.startfile`). Any exception only prints a warning without affecting main exit code.
|
|
1387
|
+
"""
|
|
1388
|
+
import webbrowser
|
|
1389
|
+
import subprocess
|
|
1390
|
+
import platform
|
|
1391
|
+
url = html_path.resolve().as_uri() # file:///...
|
|
1392
|
+
try:
|
|
1393
|
+
if webbrowser.open(url, new=2):
|
|
1394
|
+
print(f"opened : {url}")
|
|
1395
|
+
return
|
|
1396
|
+
except Exception as e: # pragma: no cover - browser env-specific
|
|
1397
|
+
print(f"warning: webbrowser.open failed: {e}", file=sys.stderr)
|
|
1398
|
+
# Fallback: invoke platform command
|
|
1399
|
+
try:
|
|
1400
|
+
sysname = platform.system()
|
|
1401
|
+
if sysname == "Darwin":
|
|
1402
|
+
subprocess.Popen(["open", str(html_path)])
|
|
1403
|
+
elif sysname == "Windows": # pragma: no cover
|
|
1404
|
+
os.startfile(str(html_path)) # type: ignore[attr-defined]
|
|
1405
|
+
else:
|
|
1406
|
+
subprocess.Popen(["xdg-open", str(html_path)])
|
|
1407
|
+
print(f"opened : {url}")
|
|
1408
|
+
except Exception as e: # pragma: no cover
|
|
1409
|
+
print(f"warning: failed to open browser ({e}); please open manually: {html_path}",
|
|
1410
|
+
file=sys.stderr)
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def _resolve_outputs(args: argparse.Namespace):
|
|
1414
|
+
"""Determine outputs based on --scenario / --with-excel / --report.
|
|
1415
|
+
|
|
1416
|
+
Returns (scenario, do_excel, do_report).
|
|
1417
|
+
- scenario: 'export' | 'refund' | 'business' | 'auto'
|
|
1418
|
+
- do_excel / do_report: whether to produce the corresponding output
|
|
1419
|
+
|
|
1420
|
+
v4 unified dashboard: all scenarios use the same HTML template (report_v4.html),
|
|
1421
|
+
no longer distinguish refund / business report types.
|
|
1422
|
+
"""
|
|
1423
|
+
scenario = (args.scenario or "auto").lower()
|
|
1424
|
+
report_flag = str(args.report).lower() not in ("false", "0", "no")
|
|
1425
|
+
|
|
1426
|
+
if scenario == "export":
|
|
1427
|
+
# Scenario 1: Excel/CSV only
|
|
1428
|
+
return scenario, True, False
|
|
1429
|
+
if scenario == "refund":
|
|
1430
|
+
# Scenario 2: Refund-focused HTML (v4 unified dashboard); Excel only with --with-excel
|
|
1431
|
+
return scenario, bool(args.with_excel), report_flag
|
|
1432
|
+
if scenario == "business":
|
|
1433
|
+
# Scenario 3: Business analysis HTML (v4 unified dashboard); Excel only with --with-excel
|
|
1434
|
+
return scenario, bool(args.with_excel), report_flag
|
|
1435
|
+
# auto: backward-compatible behavior -- Excel and v4 unified dashboard HTML both output
|
|
1436
|
+
return "auto", True, report_flag
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
# Valid status values supported by /orders/get API (test-case-required subset).
|
|
1440
|
+
# See: .qoder/doc/tech/order_api_reference.md for full API reference.
|
|
1441
|
+
VALID_STATUSES = [
|
|
1442
|
+
"all", # all orders (no filter)
|
|
1443
|
+
# -- aggregated statuses --
|
|
1444
|
+
"all_to_ship", # To Ship: topack + packed + rts
|
|
1445
|
+
"all_shipped", # Shipped: shipped + delivered
|
|
1446
|
+
# -- specific statuses --
|
|
1447
|
+
"topack", # to pack (待打包)
|
|
1448
|
+
"packed", # packed (已打包)
|
|
1449
|
+
"rts", # ready to ship / processing (待揽收)
|
|
1450
|
+
"canceled", # canceled (已取消)
|
|
1451
|
+
"shipped", # shipped / picked up (已发货/已揽收)
|
|
1452
|
+
"delivered", # delivered (已妥投)
|
|
1453
|
+
]
|
|
1454
|
+
|
|
1455
|
+
# CLI-friendly aliases (currently none)
|
|
1456
|
+
STATUS_ALIASES = {}
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _resolve_api_status(cli_status: str) -> Optional[str]:
|
|
1460
|
+
"""Map CLI --status value to the actual API parameter.
|
|
1461
|
+
|
|
1462
|
+
Returns None for 'all' (meaning no status filter).
|
|
1463
|
+
Resolves aliases (e.g. 'to_ship' → 'ready_to_ship').
|
|
1464
|
+
Other values are passed through directly to the API.
|
|
1465
|
+
"""
|
|
1466
|
+
if cli_status == "all":
|
|
1467
|
+
return None
|
|
1468
|
+
return STATUS_ALIASES.get(cli_status, cli_status)
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1472
|
+
p = argparse.ArgumentParser(
|
|
1473
|
+
prog="miravia-order-report",
|
|
1474
|
+
description="Export Miravia seller orders and generate analysis report.",
|
|
1475
|
+
)
|
|
1476
|
+
p.add_argument("--created-after")
|
|
1477
|
+
p.add_argument("--created-before")
|
|
1478
|
+
p.add_argument("--last-days", type=int, default=None,
|
|
1479
|
+
help="convenience: include today and previous N-1 days (whole-day aligned). Overrides --created-after/before.")
|
|
1480
|
+
p.add_argument("--status", default="all", choices=VALID_STATUSES,
|
|
1481
|
+
help="Order status filter (default: all). "
|
|
1482
|
+
"Aggregated: all_to_ship, all_shipped. "
|
|
1483
|
+
"Specific: topack, packed, rts, to_respond, canceled, shipped, delivered, failed_delivery, lost")
|
|
1484
|
+
p.add_argument("--channel", default=None, choices=["AE", "Miravia"],
|
|
1485
|
+
help="Channel filter: AE (M2A/AliExpress) or Miravia")
|
|
1486
|
+
p.add_argument("--ship-to", default=None,
|
|
1487
|
+
help="Destination country filter (e.g. ES, PT, IT)")
|
|
1488
|
+
p.add_argument("--order-numbers", default=None,
|
|
1489
|
+
help="Comma-separated order numbers for direct query (e.g. '12345,67890'). "
|
|
1490
|
+
"When provided, time range is still required by API.")
|
|
1491
|
+
p.add_argument("--format", default="xlsx", choices=["xlsx", "csv"])
|
|
1492
|
+
p.add_argument("--output", default="./out")
|
|
1493
|
+
p.add_argument("--max-orders", type=int, default=config.DEFAULT_MAX_ORDERS,
|
|
1494
|
+
help="Max sub-orders per export (default 10000). Exports partial data and prompts user to narrow range if exceeded.")
|
|
1495
|
+
p.add_argument("--scenario", default="auto",
|
|
1496
|
+
choices=["auto", "export", "refund", "business", "inspect"],
|
|
1497
|
+
help="Output scenario: export=Excel only; refund=refund-focused HTML; "
|
|
1498
|
+
"business=business analysis HTML; auto=Excel+unified HTML (backward-compat); "
|
|
1499
|
+
"inspect=single-order diagnostic text via --order-ids (no file output)")
|
|
1500
|
+
p.add_argument("--order-ids", default=None,
|
|
1501
|
+
help="For inspect scenario only: comma-separated trade order IDs. E.g.: 30502596014,30502596015")
|
|
1502
|
+
p.add_argument("--with-excel", action="store_true",
|
|
1503
|
+
help="Additionally output Excel details in refund/business scenarios")
|
|
1504
|
+
p.add_argument("--report", default="true",
|
|
1505
|
+
help="HTML report master switch (true/false); only effective when scenario != export")
|
|
1506
|
+
p.add_argument("--ping", action="store_true",
|
|
1507
|
+
help="health check only, no data fetch")
|
|
1508
|
+
# By default, auto-open HTML report in browser after generation; --no-open to disable
|
|
1509
|
+
p.add_argument("--open", dest="open_browser", action="store_true", default=True,
|
|
1510
|
+
help="Auto-open HTML report in default browser after generation (enabled by default)")
|
|
1511
|
+
p.add_argument("--no-open", dest="open_browser", action="store_false",
|
|
1512
|
+
help="Disable auto-opening HTML report")
|
|
1513
|
+
p.add_argument("--lang", default="en", choices=["en", "es", "it", "zh"],
|
|
1514
|
+
help="HTML report language (default: en)")
|
|
1515
|
+
p.add_argument("--agent-type", required=True,
|
|
1516
|
+
help="Agent/IDE type identifier (required), dynamically passed by the calling Agent, e.g. qoder / cursor / claude / custom_bot")
|
|
1517
|
+
return p
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def main() -> int:
|
|
1521
|
+
logging.basicConfig(level=logging.INFO,
|
|
1522
|
+
format="%(asctime)s %(levelname)s %(message)s")
|
|
1523
|
+
args = _build_parser().parse_args()
|
|
1524
|
+
# Set global agent_type (CLI required, sole source)
|
|
1525
|
+
set_agent_type(args.agent_type)
|
|
1526
|
+
# Keep args.report string raw value for _resolve_outputs; also provide boolean alias
|
|
1527
|
+
args.report_enabled = str(args.report).lower() not in ("false", "0", "no")
|
|
1528
|
+
try:
|
|
1529
|
+
return run(args)
|
|
1530
|
+
except ValueError as e:
|
|
1531
|
+
print(f"error: invalid argument: {e}", file=sys.stderr)
|
|
1532
|
+
print(
|
|
1533
|
+
"hint: --created-after/--created-before expect 'YYYY-MM-DD' or full ISO 8601.",
|
|
1534
|
+
file=sys.stderr,
|
|
1535
|
+
)
|
|
1536
|
+
write_skill_log(action=(args.scenario or "auto"), status="fail",
|
|
1537
|
+
duration_ms=0, error_code="INVALID_ARGUMENT",
|
|
1538
|
+
remark=str(e))
|
|
1539
|
+
return 1
|
|
1540
|
+
except MiraviaError as e:
|
|
1541
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1542
|
+
write_skill_log(action=(args.scenario or "auto"), status="fail",
|
|
1543
|
+
duration_ms=0, error_code=e.code,
|
|
1544
|
+
remark=str(e))
|
|
1545
|
+
return 1
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
if __name__ == "__main__":
|
|
1549
|
+
sys.exit(main())
|