@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.
@@ -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)