@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,96 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Inject LLM-generated insights into an existing v4 HTML report (SKILL.md Step 5, MANDATORY).
|
|
4
|
+
|
|
5
|
+
The built-in generate_insights() in report_v4.py only produces rule-engine placeholder summaries
|
|
6
|
+
with no real business analysis value. This script overwrites the HTML with Agent/LLM-generated
|
|
7
|
+
deep analysis, which is the mandatory step for delivering a complete report (see SKILL.md Core Rule #10).
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 scripts/inject_insights.py \\
|
|
11
|
+
--html ./out/miravia_order_report_xxx.html \\
|
|
12
|
+
--insights ./out/insights.json
|
|
13
|
+
|
|
14
|
+
The script locates the DATA declaration block in the HTML,
|
|
15
|
+
parses it as JSON, replaces the `agentInsights` field from the insights
|
|
16
|
+
file, and overwrites the HTML in-place.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def inject(html_path: Path, insights_path: Path) -> None:
|
|
28
|
+
"""Inject insights JSON into HTML report's window.DATA object."""
|
|
29
|
+
html_text = html_path.read_text(encoding="utf-8")
|
|
30
|
+
insights_data = json.loads(insights_path.read_text(encoding="utf-8"))
|
|
31
|
+
|
|
32
|
+
# Locate DATA declaration block (supports const DATA / var DATA / window.DATA)
|
|
33
|
+
patterns = [
|
|
34
|
+
re.compile(r"(const\s+DATA\s*=\s*)(.*?)(;\s*\n)", re.DOTALL),
|
|
35
|
+
re.compile(r"(var\s+DATA\s*=\s*)(.*?)(;\s*\n)", re.DOTALL),
|
|
36
|
+
re.compile(r"(window\.DATA\s*=\s*)(.*?)(;\s*\n)", re.DOTALL),
|
|
37
|
+
]
|
|
38
|
+
m = None
|
|
39
|
+
for pattern in patterns:
|
|
40
|
+
m = pattern.search(html_text)
|
|
41
|
+
if m:
|
|
42
|
+
break
|
|
43
|
+
if not m:
|
|
44
|
+
print("error: could not locate DATA declaration in HTML",
|
|
45
|
+
file=sys.stderr)
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
# Parse existing DATA
|
|
49
|
+
data_str = m.group(2)
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(data_str)
|
|
52
|
+
except json.JSONDecodeError as e:
|
|
53
|
+
print(f"error: failed to parse window.DATA JSON: {e}", file=sys.stderr)
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
# Inject agentInsights
|
|
57
|
+
data["agentInsights"] = insights_data
|
|
58
|
+
|
|
59
|
+
# Rebuild the HTML
|
|
60
|
+
new_data_str = json.dumps(data, ensure_ascii=False, default=str)
|
|
61
|
+
new_html = (
|
|
62
|
+
html_text[:m.start()]
|
|
63
|
+
+ m.group(1) + new_data_str + m.group(3)
|
|
64
|
+
+ html_text[m.end():]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
html_path.write_text(new_html, encoding="utf-8")
|
|
68
|
+
print(f"injected : {html_path}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> int:
|
|
72
|
+
p = argparse.ArgumentParser(
|
|
73
|
+
description="Inject Agent insights into v4 HTML report.")
|
|
74
|
+
p.add_argument("--html", required=True,
|
|
75
|
+
help="Path to the generated HTML report file")
|
|
76
|
+
p.add_argument("--insights", required=True,
|
|
77
|
+
help="Path to the Agent-generated insights JSON file")
|
|
78
|
+
args = p.parse_args()
|
|
79
|
+
|
|
80
|
+
html_path = Path(args.html).expanduser().resolve()
|
|
81
|
+
insights_path = Path(args.insights).expanduser().resolve()
|
|
82
|
+
|
|
83
|
+
if not html_path.exists():
|
|
84
|
+
print(f"error: HTML file not found: {html_path}", file=sys.stderr)
|
|
85
|
+
return 1
|
|
86
|
+
if not insights_path.exists():
|
|
87
|
+
print(f"error: insights file not found: {insights_path}",
|
|
88
|
+
file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
|
|
91
|
+
inject(html_path, insights_path)
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
sys.exit(main())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM ──────────────────────────────────────────────────────────────────────
|
|
3
|
+
REM login.bat — Windows CMD wrapper for auth_cli.py login
|
|
4
|
+
REM
|
|
5
|
+
REM Purpose: Bypass PowerShell Constrained Language Mode that blocks
|
|
6
|
+
REM .NET method calls (System.Convert, System.Text.Encoding) in
|
|
7
|
+
REM enterprise-managed Windows environments.
|
|
8
|
+
REM
|
|
9
|
+
REM Usage: scripts\login.bat [any auth_cli.py login arguments]
|
|
10
|
+
REM Example: scripts\login.bat --port 8765 --timeout 300
|
|
11
|
+
REM ──────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
cd /d "%~dp0.."
|
|
14
|
+
python scripts/auth_cli.py login %*
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Miravia Open API Client
|
|
5
|
+
- HMAC-SHA256 signature
|
|
6
|
+
- Auto-pagination
|
|
7
|
+
- Three-endpoint aggregation: /orders/get, /orders/items/get, /reverse/getreverseordersforseller
|
|
8
|
+
|
|
9
|
+
v0.2 Changes:
|
|
10
|
+
- Removed Miravia official OAuth (auth.miravia.com) build_authorize_url /
|
|
11
|
+
exchange_token / refresh_token trio; appKey/appSecret/accessToken
|
|
12
|
+
are exclusively sourced from the Skill Hub authorization page.
|
|
13
|
+
- Raises CredentialMissingError on initialization if credentials are empty.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hmac
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
import random
|
|
21
|
+
import sys
|
|
22
|
+
import logging
|
|
23
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
24
|
+
from urllib.parse import urlencode
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
import config
|
|
29
|
+
from skill_logger import api_tracker, SKILL_NAME, get_agent_type, detect_model_name
|
|
30
|
+
from trace_logger import record_trace
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("miravia.client")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ------------- Signature -------------
|
|
37
|
+
|
|
38
|
+
def generate_sign(api_path: str, params: Dict[str, Any], app_secret: str) -> str:
|
|
39
|
+
"""Generate HMAC-SHA256 uppercase signature per Miravia specification."""
|
|
40
|
+
filtered = {k: v for k, v in params.items()
|
|
41
|
+
if v is not None and v != "" and k != "sign"}
|
|
42
|
+
sorted_keys = sorted(filtered.keys())
|
|
43
|
+
concat = api_path + "".join(f"{k}{filtered[k]}" for k in sorted_keys)
|
|
44
|
+
return hmac.new(
|
|
45
|
+
app_secret.encode("utf-8"),
|
|
46
|
+
concat.encode("utf-8"),
|
|
47
|
+
hashlib.sha256,
|
|
48
|
+
).hexdigest().upper()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ------------- HTTP -------------
|
|
52
|
+
|
|
53
|
+
class MiraviaError(RuntimeError):
|
|
54
|
+
def __init__(self, code: str, msg: str, payload: Any = None):
|
|
55
|
+
super().__init__(f"[{code}] {msg}")
|
|
56
|
+
self.code = code
|
|
57
|
+
self.msg = msg
|
|
58
|
+
self.payload = payload
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Business error codes that should trigger retry (Core Rule #9: retry 3 times on failure)
|
|
62
|
+
_RETRYABLE_ERROR_CODES = {
|
|
63
|
+
"RateLimit", # API rate limiting
|
|
64
|
+
"ServiceUnavailable", # Temporary service unavailability
|
|
65
|
+
"SYSTEM_ERROR", # Transient server errors
|
|
66
|
+
"GatewayTimeout", # Gateway timeout
|
|
67
|
+
"TOO_MANY_REQUESTS", # HTTP 429 equivalent
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MiraviaClient:
|
|
72
|
+
def __init__(self,
|
|
73
|
+
app_key: Optional[str] = None,
|
|
74
|
+
app_secret: Optional[str] = None,
|
|
75
|
+
access_token: Optional[str] = None,
|
|
76
|
+
api_base: str = config.API_BASE,
|
|
77
|
+
action: str = "auto"):
|
|
78
|
+
# Allow caller to pass explicitly (e.g., unit tests); otherwise read from config.
|
|
79
|
+
# Business requests are gated by require_business_credentials().
|
|
80
|
+
self.app_key = app_key if app_key is not None else config.APP_KEY
|
|
81
|
+
self.app_secret = app_secret if app_secret is not None else config.APP_SECRET
|
|
82
|
+
self.access_token = access_token if access_token is not None else config.ACCESS_TOKEN
|
|
83
|
+
self.api_base = api_base.rstrip("/")
|
|
84
|
+
self.action = action
|
|
85
|
+
self.session = requests.Session()
|
|
86
|
+
|
|
87
|
+
# --- low level ---
|
|
88
|
+
|
|
89
|
+
def _common_params(self) -> Dict[str, str]:
|
|
90
|
+
"""Build common parameters for all API requests.
|
|
91
|
+
|
|
92
|
+
Includes Miravia gateway-required signing params + 4 Skill Hub passthrough tracking params:
|
|
93
|
+
- skill_name : Skill identifier (fixed: miravia_order_reports)
|
|
94
|
+
- action : Current invocation scenario/action (export / refund / business / inspect / ping)
|
|
95
|
+
- agent_type : Agent/IDE type (qoder / cursor / claude ...)
|
|
96
|
+
- model_name : Agent's LLM model name (may be empty)
|
|
97
|
+
These 4 params are included in every request to /orders/get, /orders/items/get,
|
|
98
|
+
/reverse/getreverseordersforseller and participate in HMAC-SHA256 signing.
|
|
99
|
+
"""
|
|
100
|
+
params = {
|
|
101
|
+
"app_key": self.app_key,
|
|
102
|
+
"access_token": self.access_token,
|
|
103
|
+
"sign_method": config.SIGN_METHOD,
|
|
104
|
+
"timestamp": str(int(time.time() * 1000)),
|
|
105
|
+
"skill_name": SKILL_NAME,
|
|
106
|
+
"action": self.action,
|
|
107
|
+
"agent_type": get_agent_type(),
|
|
108
|
+
}
|
|
109
|
+
# model_name may be empty (undetectable); omit empty values to keep signature consistent
|
|
110
|
+
model = detect_model_name()
|
|
111
|
+
if model:
|
|
112
|
+
params["model_name"] = model
|
|
113
|
+
return params
|
|
114
|
+
|
|
115
|
+
def _request(self, method: str, api_path: str,
|
|
116
|
+
params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
117
|
+
config.require_business_credentials()
|
|
118
|
+
params = {k: v for k, v in (params or {}).items() if v is not None and v != ""}
|
|
119
|
+
params.update(self._common_params())
|
|
120
|
+
params["sign"] = generate_sign(api_path, params, self.app_secret)
|
|
121
|
+
|
|
122
|
+
url = f"{self.api_base}{api_path}"
|
|
123
|
+
last_exc: Optional[Exception] = None
|
|
124
|
+
for attempt in range(1, config.HTTP_RETRY + 1):
|
|
125
|
+
try:
|
|
126
|
+
if method.upper() == "GET":
|
|
127
|
+
resp = self.session.get(
|
|
128
|
+
url + "?" + urlencode(params),
|
|
129
|
+
timeout=config.HTTP_TIMEOUT,
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
resp = self.session.post(
|
|
133
|
+
url, data=params, timeout=config.HTTP_TIMEOUT,
|
|
134
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
135
|
+
)
|
|
136
|
+
resp.raise_for_status()
|
|
137
|
+
body = resp.json()
|
|
138
|
+
# Extract traceId from response body (_trace_id_) or header (EagleEye-TraceId)
|
|
139
|
+
trace_id = (body.get("_trace_id_")
|
|
140
|
+
or resp.headers.get("EagleEye-TraceId")
|
|
141
|
+
or "")
|
|
142
|
+
code = str(body.get("code", "0"))
|
|
143
|
+
if code != "0":
|
|
144
|
+
# Retryable business error codes (rate limit, service unavailable)
|
|
145
|
+
if code in _RETRYABLE_ERROR_CODES and attempt < config.HTTP_RETRY:
|
|
146
|
+
api_tracker.record(api_path, success=False)
|
|
147
|
+
record_trace(api_path, trace_id, success=False, params=params)
|
|
148
|
+
sleep_s = min(2 ** attempt, 30) * (0.5 + random.random())
|
|
149
|
+
logger.warning(
|
|
150
|
+
"API %s %s attempt=%d retryable code=%s, sleeping %.1fs",
|
|
151
|
+
method, api_path, attempt, code, sleep_s,
|
|
152
|
+
)
|
|
153
|
+
time.sleep(sleep_s)
|
|
154
|
+
continue
|
|
155
|
+
api_tracker.record(api_path, success=False)
|
|
156
|
+
record_trace(api_path, trace_id, success=False, params=params)
|
|
157
|
+
raise MiraviaError(code, body.get("message", ""), body)
|
|
158
|
+
api_tracker.record(api_path, success=True)
|
|
159
|
+
record_trace(api_path, trace_id, success=True, params=params)
|
|
160
|
+
return body
|
|
161
|
+
except (requests.RequestException, ValueError) as e:
|
|
162
|
+
last_exc = e
|
|
163
|
+
logger.warning("HTTP %s %s attempt=%d err=%s", method, api_path, attempt, e)
|
|
164
|
+
# Exponential backoff with jitter
|
|
165
|
+
time.sleep(min(2 ** attempt, 30) * (0.5 + random.random()))
|
|
166
|
+
api_tracker.record(api_path, success=False)
|
|
167
|
+
record_trace(api_path, None, success=False, params=params)
|
|
168
|
+
assert last_exc is not None
|
|
169
|
+
raise MiraviaError("HTTP_FAIL", str(last_exc))
|
|
170
|
+
|
|
171
|
+
# --- skill upgrade check ---
|
|
172
|
+
|
|
173
|
+
def check_skill_upgrade(self, current_version: Optional[str] = None,
|
|
174
|
+
skill_code: Optional[str] = None) -> Dict[str, Any]:
|
|
175
|
+
"""Check if a newer skill version is available on the server.
|
|
176
|
+
|
|
177
|
+
Calls /order/skill/check/upgrade and returns the response data.
|
|
178
|
+
Returns dict with keys:
|
|
179
|
+
- need_upgrade: 1 if upgrade available, 0 otherwise
|
|
180
|
+
- latest_skill: dict with version, package_url, npx_command, etc.
|
|
181
|
+
|
|
182
|
+
Non-blocking: caller should catch exceptions and degrade gracefully.
|
|
183
|
+
"""
|
|
184
|
+
params = {
|
|
185
|
+
"skill_code": skill_code or config.DEFAULT_SKILL_CODE,
|
|
186
|
+
"current_version": current_version or config.SKILL_VERSION,
|
|
187
|
+
}
|
|
188
|
+
return self._request("GET", "/order/skill/check/upgrade", params).get("data", {})
|
|
189
|
+
|
|
190
|
+
# --- ping ---
|
|
191
|
+
|
|
192
|
+
def ping(self) -> Dict[str, Any]:
|
|
193
|
+
"""Lightweight health check: calls /seller/get (cheapest endpoint)."""
|
|
194
|
+
return self._request("GET", "/seller/get")
|
|
195
|
+
|
|
196
|
+
# --- orders ---
|
|
197
|
+
|
|
198
|
+
# /orders/get gateway limit: offset + limit must not exceed ORDERS_PER_WINDOW.
|
|
199
|
+
# Testing shows API returns empty data at offset >= ORDERS_PER_WINDOW (inflated countTotal issue).
|
|
200
|
+
ORDERS_GET_OFFSET_LIMIT_CAP = config.ORDERS_PER_WINDOW
|
|
201
|
+
|
|
202
|
+
def get_orders_page(self, created_after: str, created_before: str,
|
|
203
|
+
status: Optional[str] = None,
|
|
204
|
+
limit: int = config.PAGE_SIZE,
|
|
205
|
+
offset: int = 0,
|
|
206
|
+
channel: Optional[str] = None,
|
|
207
|
+
ship_to: Optional[str] = None,
|
|
208
|
+
order_numbers: Optional[str] = None) -> Dict[str, Any]:
|
|
209
|
+
"""Fetch a single page of orders from /orders/get.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
status: API status value (e.g. 'ready_to_ship', 'shipped', 'all_to_ship').
|
|
213
|
+
channel: Channel filter - maps to API 'marketplace' param. Values: 'AE' (→'ae') or 'Miravia'.
|
|
214
|
+
ship_to: Receiving country filter - maps to API 'country' param ('ES', 'PT').
|
|
215
|
+
order_numbers: Comma-separated order numbers (e.g. '12345,67890').
|
|
216
|
+
"""
|
|
217
|
+
# Note: official docs misspell the param as `limt`; the gateway actually accepts `limit`.
|
|
218
|
+
params: Dict[str, Any] = {
|
|
219
|
+
"created_after": created_after,
|
|
220
|
+
"created_before": created_before,
|
|
221
|
+
"limit": limit,
|
|
222
|
+
"offset": offset,
|
|
223
|
+
}
|
|
224
|
+
if status and status != "all":
|
|
225
|
+
params["status"] = status # e.g. "pending" / "ready_to_ship" / "shipped"
|
|
226
|
+
if channel:
|
|
227
|
+
# API actual param name is 'marketplace'; values are lowercase 'ae' or 'Miravia'
|
|
228
|
+
params["marketplace"] = channel.lower() if channel.upper() == "AE" else channel
|
|
229
|
+
if ship_to:
|
|
230
|
+
params["country"] = ship_to
|
|
231
|
+
if order_numbers:
|
|
232
|
+
params["orderNumbers"] = order_numbers
|
|
233
|
+
return self._request("GET", "/orders/get", params)["data"]
|
|
234
|
+
|
|
235
|
+
def iter_orders(self, created_after: str, created_before: str,
|
|
236
|
+
status: Optional[str] = None,
|
|
237
|
+
max_orders: int = config.DEFAULT_MAX_ORDERS,
|
|
238
|
+
quiet: bool = False,
|
|
239
|
+
channel: Optional[str] = None,
|
|
240
|
+
ship_to: Optional[str] = None,
|
|
241
|
+
order_numbers: Optional[str] = None) -> Iterable[Dict[str, Any]]:
|
|
242
|
+
"""Fetch order list with auto-pagination.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
- max_orders: Maximum number of orders to return per invocation.
|
|
246
|
+
- quiet: If True, suppress progress output (used in internal auto-slicing calls).
|
|
247
|
+
- channel: Channel filter ('AE' for M2A, 'Miravia').
|
|
248
|
+
- ship_to: Destination country filter ('ES', 'PT', 'IT').
|
|
249
|
+
- order_numbers: Comma-separated order numbers.
|
|
250
|
+
|
|
251
|
+
Pagination formula: offset = (pageNum - 1) * limit
|
|
252
|
+
- Page 1 offset=0, Page 2 offset=100, Page 3 offset=200 ...
|
|
253
|
+
- offset must be an integer multiple of limit; otherwise the API returns misaligned data.
|
|
254
|
+
- Server hard limit: offset + limit <= 10000; split time window if exceeded.
|
|
255
|
+
"""
|
|
256
|
+
page_size = config.PAGE_SIZE
|
|
257
|
+
page_num = 1
|
|
258
|
+
fetched = 0
|
|
259
|
+
total = None
|
|
260
|
+
while True:
|
|
261
|
+
offset = (page_num - 1) * page_size
|
|
262
|
+
# Server hard limit: offset + limit <= 10000. Exit early to avoid gateway error.
|
|
263
|
+
if offset + page_size > self.ORDERS_GET_OFFSET_LIMIT_CAP:
|
|
264
|
+
if not quiet:
|
|
265
|
+
print(
|
|
266
|
+
f"progress: current window exceeds single-query limit, auto-splitting time range",
|
|
267
|
+
flush=True,
|
|
268
|
+
)
|
|
269
|
+
return
|
|
270
|
+
data = self.get_orders_page(created_after, created_before,
|
|
271
|
+
status=status,
|
|
272
|
+
limit=page_size,
|
|
273
|
+
offset=offset,
|
|
274
|
+
channel=channel,
|
|
275
|
+
ship_to=ship_to,
|
|
276
|
+
order_numbers=order_numbers)
|
|
277
|
+
orders = data.get("orders", []) or []
|
|
278
|
+
if total is None:
|
|
279
|
+
total = int(data.get("countTotal", len(orders)))
|
|
280
|
+
if not quiet:
|
|
281
|
+
print(f"progress: window has data, fetching...", flush=True)
|
|
282
|
+
if not orders:
|
|
283
|
+
return
|
|
284
|
+
for o in orders:
|
|
285
|
+
yield o
|
|
286
|
+
fetched += 1
|
|
287
|
+
if fetched >= max_orders:
|
|
288
|
+
return
|
|
289
|
+
if not quiet:
|
|
290
|
+
print(f"progress: fetched {fetched} orders", flush=True)
|
|
291
|
+
# Page not full or reached total — no more data
|
|
292
|
+
if len(orders) < page_size or fetched >= total:
|
|
293
|
+
return
|
|
294
|
+
page_num += 1
|
|
295
|
+
|
|
296
|
+
# --- order items ---
|
|
297
|
+
|
|
298
|
+
def get_multi_order_items(self, order_ids: List[Any],
|
|
299
|
+
concurrent: bool = False) -> List[Dict[str, Any]]:
|
|
300
|
+
"""/orders/items/get supports batch queries; here we call in batches of 50.
|
|
301
|
+
|
|
302
|
+
When concurrent=True, uses thread pool to fetch batches concurrently.
|
|
303
|
+
"""
|
|
304
|
+
if not order_ids:
|
|
305
|
+
return []
|
|
306
|
+
BATCH = 50
|
|
307
|
+
batches = [order_ids[i:i + BATCH] for i in range(0, len(order_ids), BATCH)]
|
|
308
|
+
|
|
309
|
+
def _fetch_batch(batch: List[Any]) -> List[Dict[str, Any]]:
|
|
310
|
+
ids_str = "[" + ",".join(str(x) for x in batch) + "]"
|
|
311
|
+
data = self._request("GET", "/orders/items/get",
|
|
312
|
+
{"order_ids": ids_str})["data"]
|
|
313
|
+
if isinstance(data, list):
|
|
314
|
+
return data
|
|
315
|
+
elif isinstance(data, dict) and "orders" in data:
|
|
316
|
+
return data["orders"]
|
|
317
|
+
return []
|
|
318
|
+
|
|
319
|
+
if not concurrent or len(batches) <= 3:
|
|
320
|
+
# Serial mode
|
|
321
|
+
out: List[Dict[str, Any]] = []
|
|
322
|
+
for batch in batches:
|
|
323
|
+
out.extend(_fetch_batch(batch))
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
# Concurrent mode
|
|
327
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
328
|
+
out: List[Dict[str, Any]] = []
|
|
329
|
+
with ThreadPoolExecutor(max_workers=config.CONCURRENT_WORKERS) as pool:
|
|
330
|
+
futures = [pool.submit(_fetch_batch, b) for b in batches]
|
|
331
|
+
for future in as_completed(futures):
|
|
332
|
+
try:
|
|
333
|
+
out.extend(future.result())
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.warning("items batch failed: %s", e)
|
|
336
|
+
return out
|
|
337
|
+
|
|
338
|
+
# --- reverse ---
|
|
339
|
+
|
|
340
|
+
def get_reverse_orders(self,
|
|
341
|
+
created_after_ms: Optional[int] = None,
|
|
342
|
+
created_before_ms: Optional[int] = None,
|
|
343
|
+
reverse_status_list: Optional[List[str]] = None,
|
|
344
|
+
page_size: int = 100,
|
|
345
|
+
max_pages: int = 30,
|
|
346
|
+
# Below are single-record filter params per official docs. See reference.md "Reverse Order Full API".
|
|
347
|
+
trade_order_id: Optional[Any] = None,
|
|
348
|
+
reverse_order_id: Optional[Any] = None,
|
|
349
|
+
reverse_tracking_number: Optional[str] = None,
|
|
350
|
+
ofc_status_list: Optional[List[str]] = None,
|
|
351
|
+
dispute_in_progress: Optional[bool] = None,
|
|
352
|
+
marketplace: Optional[str] = None,
|
|
353
|
+
reverse_line_after_ms: Optional[int] = None,
|
|
354
|
+
reverse_line_before_ms: Optional[int] = None,
|
|
355
|
+
) -> List[Dict[str, Any]]:
|
|
356
|
+
"""Fetch reverse orders via /reverse/getreverseordersforseller.
|
|
357
|
+
|
|
358
|
+
Official docs full params (1 required pagination + 11 optional filters):
|
|
359
|
+
- page_size / page_no (required)
|
|
360
|
+
- trade_order_id / reverse_order_id / reverse_tracking_number (single-record filters)
|
|
361
|
+
- trade_order_time_range_start/end_time_stamp (main order creation time window, ms)
|
|
362
|
+
- reverse_order_line_time_range_start/end_time_stamp (reverse line creation time window)
|
|
363
|
+
- reverse_status_list / ofc_status_list (JSON array strings)
|
|
364
|
+
- dispute_in_progress / marketplace
|
|
365
|
+
|
|
366
|
+
Pitfalls:
|
|
367
|
+
- Param names `start_create_time` / `end_create_time` / singular `status` do not exist;
|
|
368
|
+
they are silently discarded by the server and return all data. Do not use.
|
|
369
|
+
- Server has a deep-page limit at `page_no >= ~31` (offset > 3000); this function
|
|
370
|
+
uses `max_pages` to cap, avoiding SYSTEM_ERROR; split time window to re-fetch if total exceeds limit.
|
|
371
|
+
- Single-order scenarios (with trade_order_id / reverse_order_id) typically don't need
|
|
372
|
+
a time window; this function skips the "no time window" guard error for those.
|
|
373
|
+
"""
|
|
374
|
+
# Safety check: require at least one filter dimension to avoid pulling all data.
|
|
375
|
+
single_filter = (trade_order_id is not None
|
|
376
|
+
or reverse_order_id is not None
|
|
377
|
+
or reverse_tracking_number)
|
|
378
|
+
time_window = (created_after_ms is not None and created_before_ms is not None) or \
|
|
379
|
+
(reverse_line_after_ms is not None and reverse_line_before_ms is not None)
|
|
380
|
+
if not single_filter and not time_window:
|
|
381
|
+
raise ValueError(
|
|
382
|
+
"get_reverse_orders requires at least one filter: "
|
|
383
|
+
"trade_order_id / reverse_order_id / reverse_tracking_number, "
|
|
384
|
+
"or a time window (created_after_ms+created_before_ms)."
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
out: List[Dict[str, Any]] = []
|
|
388
|
+
seen: set = set()
|
|
389
|
+
page = 1
|
|
390
|
+
total: Optional[int] = None
|
|
391
|
+
while page <= max_pages:
|
|
392
|
+
params: Dict[str, Any] = {
|
|
393
|
+
"page_size": page_size,
|
|
394
|
+
"page_no": page,
|
|
395
|
+
}
|
|
396
|
+
if created_after_ms is not None:
|
|
397
|
+
params["trade_order_time_range_start_time_stamp"] = created_after_ms
|
|
398
|
+
if created_before_ms is not None:
|
|
399
|
+
params["trade_order_time_range_end_time_stamp"] = created_before_ms
|
|
400
|
+
if reverse_line_after_ms is not None:
|
|
401
|
+
params["reverse_order_line_time_range_start_time_stamp"] = reverse_line_after_ms
|
|
402
|
+
if reverse_line_before_ms is not None:
|
|
403
|
+
params["reverse_order_line_time_range_end_time_stamp"] = reverse_line_before_ms
|
|
404
|
+
if trade_order_id is not None:
|
|
405
|
+
params["trade_order_id"] = trade_order_id
|
|
406
|
+
if reverse_order_id is not None:
|
|
407
|
+
params["reverse_order_id"] = reverse_order_id
|
|
408
|
+
if reverse_tracking_number:
|
|
409
|
+
params["reverse_tracking_number"] = reverse_tracking_number
|
|
410
|
+
if reverse_status_list:
|
|
411
|
+
# Docs show passing as JSON array string
|
|
412
|
+
params["reverse_status_list"] = json.dumps(reverse_status_list)
|
|
413
|
+
if ofc_status_list:
|
|
414
|
+
params["ofc_status_list"] = json.dumps(ofc_status_list)
|
|
415
|
+
if dispute_in_progress is not None:
|
|
416
|
+
# API expects string 'true' / 'false'
|
|
417
|
+
params["dispute_in_progress"] = "true" if dispute_in_progress else "false"
|
|
418
|
+
if marketplace:
|
|
419
|
+
params["marketplace"] = marketplace
|
|
420
|
+
try:
|
|
421
|
+
data = self._request("GET",
|
|
422
|
+
"/reverse/getreverseordersforseller",
|
|
423
|
+
params)["data"] or {}
|
|
424
|
+
except MiraviaError as e:
|
|
425
|
+
# Graceful degradation: log warning and return data fetched so far
|
|
426
|
+
logger.warning("reverse page=%d failed: %s", page, e)
|
|
427
|
+
break
|
|
428
|
+
items = data.get("items") or []
|
|
429
|
+
if total is None:
|
|
430
|
+
total = int(data.get("total") or 0)
|
|
431
|
+
logger.info("reverse total=%d (filters=%s)",
|
|
432
|
+
total,
|
|
433
|
+
{k: v for k, v in params.items()
|
|
434
|
+
if k not in ("page_size", "page_no")})
|
|
435
|
+
for r in items:
|
|
436
|
+
rid = (r.get("reverse_order_id") or r.get("dispute_id")
|
|
437
|
+
or id(r))
|
|
438
|
+
if rid in seen:
|
|
439
|
+
continue
|
|
440
|
+
seen.add(rid)
|
|
441
|
+
out.append(r)
|
|
442
|
+
if not items or len(items) < page_size or len(out) >= (total or 0):
|
|
443
|
+
break
|
|
444
|
+
page += 1
|
|
445
|
+
if total is not None and total > max_pages * page_size:
|
|
446
|
+
logger.warning(
|
|
447
|
+
"reverse total=%d exceeds max_pages*page_size=%d; "
|
|
448
|
+
"caller should narrow the time window and retry.",
|
|
449
|
+
total, max_pages * page_size)
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ------------- CLI for ad-hoc use -------------
|
|
454
|
+
|
|
455
|
+
if __name__ == "__main__":
|
|
456
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
457
|
+
try:
|
|
458
|
+
config.require_business_credentials()
|
|
459
|
+
except config.CredentialMissingError as e:
|
|
460
|
+
print(str(e), file=sys.stderr)
|
|
461
|
+
sys.exit(1)
|
|
462
|
+
cli = MiraviaClient()
|
|
463
|
+
try:
|
|
464
|
+
cli.ping()
|
|
465
|
+
print("ping OK")
|
|
466
|
+
except MiraviaError as e:
|
|
467
|
+
print(f"ping FAIL: {e}", file=sys.stderr)
|
|
468
|
+
sys.exit(1)
|