@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,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())