@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,322 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Test script for v4 unified dashboard rendering.
4
+
5
+ Generates mock order data and calls report_v4.render() to produce HTML files.
6
+ Tests three scenarios: business (rich data), refund (high refund rate), and empty data.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ import sys
12
+ from datetime import datetime, timedelta
13
+ from pathlib import Path
14
+
15
+ # Ensure scripts dir is on path
16
+ THIS_DIR = Path(__file__).resolve().parent
17
+ sys.path.insert(0, str(THIS_DIR))
18
+
19
+ # Import from export_orders
20
+ from export_orders import EXPORT_COLUMNS, _jinja_env
21
+ import report_v4
22
+ from i18n import SUPPORTED_LANGS
23
+
24
+ random.seed(42)
25
+
26
+ # ---------- mock data generators ----------
27
+
28
+ PRODUCTS = [
29
+ ("SKU-LED-001", "LED Smart Lamp Aurora", 29.99, "Home & Living"),
30
+ ("SKU-LED-002", "LED Strip Light 5m RGB", 15.99, "Home & Living"),
31
+ ("SKU-AUR-001", "Auriculares Bluetooth Pro", 49.99, "Electronics"),
32
+ ("SKU-AUR-002", "Altavoz Bluetooth Portable", 34.50, "Electronics"),
33
+ ("SKU-CAR-001", "Cargador USB-C 65W", 24.99, "Electronics"),
34
+ ("SKU-SAR-001", "Sartén Antiadherente 28cm", 22.00, "Kitchen"),
35
+ ("SKU-COC-001", "Cafetera Espresso Automática", 89.99, "Kitchen"),
36
+ ("SKU-MOC-001", "Mochila senderismo 40L", 39.99, "Sports & Outdoors"),
37
+ ("SKU-CRE-001", "Crema hidratante facial", 12.99, "Beauty & Personal Care"),
38
+ ("SKU-CAM-001", "Camiseta algodón premium", 14.99, "Fashion & Accessories"),
39
+ ("SKU-ZAP-001", "Zapato running deportivo", 55.00, "Fashion & Accessories"),
40
+ ("SKU-BEB-001", "Juguetes educativos bebé", 27.99, "Baby & Toys"),
41
+ ("SKU-CAR-002", "Coche control remoto RC", 45.00, "Auto Parts"),
42
+ ("SKU-LAM-001", "Lámpara de mesa madera", 33.50, "Home & Living"),
43
+ ("SKU-PER-001", "Perfume floral 100ml", 28.99, "Beauty & Personal Care"),
44
+ ("SKU-COC-002", "Cuchillo chef acero inox", 18.50, "Kitchen"),
45
+ ]
46
+
47
+ CARRIERS = ["CTT Express", "DHL", "SEUR", "MRW", "Correos", "GLS"]
48
+ CITIES = ["Madrid", "Barcelona", "Valencia", "Sevilla", "Bilbao", "Málaga", "Zaragoza"]
49
+ COUNTRIES = ["ES", "PT", "FR", "DE", "IT"]
50
+ PAY_METHODS = ["Credit Card", "PayPal", "Klarna", "Bizum", "Apple Pay"]
51
+ CANCEL_REASONS = ["Out of stock", "Buyer request", "Price error", "Shipping delay", "Address issue"]
52
+ CANCEL_INITIATORS = ["buyer", "seller", "system"]
53
+ REFUND_REASONS = [
54
+ "Product not as described",
55
+ "Damaged in transit",
56
+ "Wrong item received",
57
+ "Late delivery",
58
+ "Quality issue",
59
+ "Buyer changed mind",
60
+ "Missing parts",
61
+ ]
62
+ REFUND_TYPES = ["RETURN_REFUND", "MONEY_ONLY", "RESEND"]
63
+ DUTY_PARTIES = ["buyer", "seller", "pending"]
64
+ SHIPMENT_TYPES = ["standard", "express", "pickup"]
65
+ ITEM_STATUSES = ["shipped", "delivered", "pending", "canceled", "returned"]
66
+
67
+
68
+ def _gen_order_id(i):
69
+ return f"ORD-{20260600 + i:07d}"
70
+
71
+
72
+ def _gen_item_id(i):
73
+ return f"ITM-{30000000 + i:08d}"
74
+
75
+
76
+ def _gen_buyer_id(i):
77
+ return f"BUY-{50000 + i:05d}"
78
+
79
+
80
+ def _gen_refund_id(i):
81
+ return f"RFD-{800000 + i:06d}"
82
+
83
+
84
+ def _gen_tracking(i):
85
+ return f"TRK{100000000 + i:09d}"
86
+
87
+
88
+ def gen_mock_rows(n_days=14, n_orders_per_day=8, refund_rate=0.18, cancel_rate=0.12,
89
+ miravia_ratio=0.6):
90
+ """Generate mock sub-order rows for the v4 dashboard."""
91
+ rows = []
92
+ idx = 0
93
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
94
+
95
+ for d in range(n_days):
96
+ date = today - timedelta(days=n_days - 1 - d)
97
+ date_str = date.strftime("%Y-%m-%d %H:%M:%S")
98
+
99
+ for o in range(n_orders_per_day):
100
+ order_id = _gen_order_id(idx)
101
+ is_miravia = random.random() < miravia_ratio
102
+ marketplace = "miravia" if is_miravia else "aliexpress"
103
+ buyer_id = _gen_buyer_id(idx % 200) # Some repeat buyers
104
+ country = random.choice(COUNTRIES)
105
+ city = random.choice(CITIES)
106
+ pay_method = random.choice(PAY_METHODS)
107
+ carrier = random.choice(CARRIERS)
108
+ ship_type = random.choice(SHIPMENT_TYPES)
109
+ items_count = random.randint(1, 3)
110
+
111
+ for item_i in range(items_count):
112
+ sku, name, price, cat = random.choice(PRODUCTS)
113
+ qty = random.randint(1, 2)
114
+ unit_price = price
115
+ discount_seller = round(price * random.choice([0, 0, 0, 0.05, 0.10, 0.15]), 2)
116
+ buyer_paid = round((unit_price - discount_seller) * qty, 2)
117
+ shipping_paid = round(random.choice([0, 0, 4.99, 6.99, 9.99]), 2)
118
+ item_status = random.choices(ITEM_STATUSES, weights=[30, 35, 15, 10, 10])[0]
119
+
120
+ is_canceled = random.random() < cancel_rate
121
+ if is_canceled:
122
+ item_status = "canceled"
123
+ cancel_initiator = random.choice(CANCEL_INITIATORS)
124
+ cancel_reason = random.choice(CANCEL_REASONS)
125
+ else:
126
+ cancel_initiator = ""
127
+ cancel_reason = ""
128
+
129
+ is_refund = (not is_canceled) and (random.random() < refund_rate)
130
+ if is_refund:
131
+ refund_id = _gen_refund_id(idx)
132
+ refund_apply_at = date_str
133
+ refund_status = random.choice(["SUCCESS", "PROCESSING", "FAILED"])
134
+ refund_amount = round(buyer_paid * random.uniform(0.5, 1.0), 2)
135
+ refund_reason = random.choice(REFUND_REASONS)
136
+ is_fast_refund = random.random() < 0.3
137
+ is_instant_refund = random.random() < 0.15
138
+ refund_tracking = _gen_tracking(idx) if random.random() < 0.4 else ""
139
+ request_type = random.choice(REFUND_TYPES)
140
+ duty_party = random.choice(DUTY_PARTIES)
141
+ seller_agreed = random.random() < 0.7
142
+ refund_payment = random.choice(PAY_METHODS)
143
+ reason_code = f"RC{random.randint(1000, 9999)}"
144
+ else:
145
+ refund_id = ""
146
+ refund_apply_at = ""
147
+ refund_status = ""
148
+ refund_amount = ""
149
+ refund_reason = ""
150
+ is_fast_refund = False
151
+ is_instant_refund = False
152
+ refund_tracking = ""
153
+ request_type = ""
154
+ duty_party = ""
155
+ seller_agreed = ""
156
+ refund_payment = ""
157
+ reason_code = ""
158
+
159
+ row = {
160
+ # Order
161
+ "order_id": order_id,
162
+ "marketplace": marketplace,
163
+ "created_at": date_str,
164
+ "items_count": items_count,
165
+ "payment_method": pay_method,
166
+ "buyer_id": buyer_id,
167
+ "buyer_name": f"Customer {buyer_id}",
168
+ "ship_address": f"Street {random.randint(1, 999)}, {city}",
169
+ "ship_country": country,
170
+ "warehouse_code": f"WH-{random.choice(['MAD', 'BCN', 'VAL'])}",
171
+ "buyer_remark": random.choice(["", "", "Please ship fast"]),
172
+ "seller_remark": random.choice(["", "", "VIP customer"]),
173
+ # Item
174
+ "order_item_id": _gen_item_id(idx),
175
+ "item_status": item_status,
176
+ "item_updated_at": date_str,
177
+ # Product
178
+ "product_id": f"PID-{idx:06d}",
179
+ "product_name": name,
180
+ "product_url": f"https://miravia.es/p/{idx}",
181
+ "sku": sku,
182
+ "shop_sku": f"SS-{sku}",
183
+ "quantity": qty,
184
+ "unit_price": unit_price,
185
+ # Amount
186
+ "buyer_paid": buyer_paid,
187
+ "discount_total": discount_seller,
188
+ "discount_platform": round(discount_seller * 0.3, 2),
189
+ "discount_seller": discount_seller,
190
+ "shipping_paid": shipping_paid,
191
+ "shipping_disc_total": round(shipping_paid * 0.5, 2) if shipping_paid > 0 else 0,
192
+ "shipping_disc_platform": 0,
193
+ "shipping_disc_seller": round(shipping_paid * 0.3, 2) if shipping_paid > 0 else 0,
194
+ "currency": "EUR",
195
+ # Logistics
196
+ "shipment_type": ship_type,
197
+ "tracking_no": _gen_tracking(idx) if not is_canceled else "",
198
+ "package_id": f"PKG-{idx:06d}",
199
+ "shipping_provider": carrier,
200
+ # Cancellation
201
+ "is_canceled": is_canceled,
202
+ "cancel_initiator": cancel_initiator,
203
+ "cancel_reason": cancel_reason,
204
+ # Refund
205
+ "refund_id": refund_id,
206
+ "refund_apply_at": refund_apply_at,
207
+ "refund_status": refund_status,
208
+ "refund_amount": refund_amount,
209
+ "refund_reason": refund_reason,
210
+ "is_fast_refund": is_fast_refund,
211
+ "is_instant_refund": is_instant_refund,
212
+ "refund_tracking_no": refund_tracking,
213
+ # ---- v4 additions ----
214
+ "city": city,
215
+ "ship_to": f"Recipient {idx}",
216
+ "channel": "online",
217
+ "order_flag": random.choice(["", "", "priority", "gift"]),
218
+ "is_fbl": 1 if random.random() < 0.25 else 0,
219
+ "is_digital": 1 if random.random() < 0.05 else 0,
220
+ "is_sof": 1 if random.random() < 0.10 else 0,
221
+ "shipping_fee_original": shipping_paid,
222
+ "shipping_fee_disc_seller": round(shipping_paid * 0.3, 2) if shipping_paid > 0 else 0,
223
+ "voucher_seller_lpi": round(discount_seller, 2) if discount_seller > 0 else 0,
224
+ "variation": f"Variant-{random.choice(['A', 'B', 'C'])}",
225
+ "returnable": 1 if not is_canceled else 0,
226
+ "request_type": request_type,
227
+ "duty_party": duty_party,
228
+ "is_not_received": 1 if (is_refund and random.random() < 0.3) else 0,
229
+ "refund_payment_method": refund_payment,
230
+ "reason_code": reason_code,
231
+ "ofc_status": random.choice(["", "OK", "WARNING"]),
232
+ "refund_currency": "EUR" if is_refund else "",
233
+ "seller_sku_id": f"SID-{sku}",
234
+ "seller_agreed": seller_agreed,
235
+ }
236
+ rows.append(row)
237
+ idx += 1
238
+
239
+ return rows
240
+
241
+
242
+ def main():
243
+ out_dir = THIS_DIR.parent / "out"
244
+ out_dir.mkdir(exist_ok=True)
245
+ jinja_env = _jinja_env()
246
+
247
+ # ---- Scenario 1: Business (rich data, balanced) — all languages ----
248
+ print("=" * 60)
249
+ print("Scenario 1: business (rich data) — multi-language")
250
+ print("=" * 60)
251
+ rows = gen_mock_rows(n_days=14, n_orders_per_day=8, refund_rate=0.15, cancel_rate=0.10)
252
+ for lang in SUPPORTED_LANGS:
253
+ out_path = out_dir / f"test_v4_business_{lang}.html"
254
+ report_v4.render(
255
+ rows=rows, out_path=out_path,
256
+ range_label="2026-06-08 ~ 2026-06-21",
257
+ jinja_env=jinja_env, export_columns=EXPORT_COLUMNS,
258
+ default_max_orders=10000, lang=lang,
259
+ )
260
+ print(f" [{lang}] Generated: {out_path} ({out_path.stat().st_size / 1024:.1f} KB)")
261
+ print(f" Rows: {len(rows)}")
262
+ # Keep reference to en version for browser open
263
+ out_path = out_dir / "test_v4_business_en.html"
264
+
265
+ # ---- Scenario 2: Refund (high refund rate) ----
266
+ print("\n" + "=" * 60)
267
+ print("Scenario 2: refund (high refund rate)")
268
+ print("=" * 60)
269
+ rows2 = gen_mock_rows(n_days=30, n_orders_per_day=6, refund_rate=0.35, cancel_rate=0.08)
270
+ out_path2 = out_dir / "test_v4_refund.html"
271
+ report_v4.render(
272
+ rows=rows2, out_path=out_path2,
273
+ range_label="2026-05-23 ~ 2026-06-21",
274
+ jinja_env=jinja_env, export_columns=EXPORT_COLUMNS,
275
+ default_max_orders=10000, lang="es",
276
+ )
277
+ print(f" Generated: {out_path2} ({out_path2.stat().st_size / 1024:.1f} KB)")
278
+ print(f" Rows: {len(rows2)}")
279
+
280
+ # ---- Scenario 3: Empty data (degradation test) ----
281
+ print("\n" + "=" * 60)
282
+ print("Scenario 3: empty data (degradation)")
283
+ print("=" * 60)
284
+ out_path3 = out_dir / "test_v4_empty.html"
285
+ report_v4.render(
286
+ rows=[], out_path=out_path3,
287
+ range_label="2026-06-15 ~ 2026-06-21",
288
+ jinja_env=jinja_env, export_columns=EXPORT_COLUMNS,
289
+ default_max_orders=10000, lang="zh",
290
+ )
291
+ print(f" Generated: {out_path3} ({out_path3.stat().st_size / 1024:.1f} KB)")
292
+ print(f" Rows: 0")
293
+
294
+ # ---- Scenario 4: Auto (mixed, small dataset) ----
295
+ print("\n" + "=" * 60)
296
+ print("Scenario 4: auto (small dataset, 3 days)")
297
+ print("=" * 60)
298
+ rows4 = gen_mock_rows(n_days=3, n_orders_per_day=5, refund_rate=0.25, cancel_rate=0.20,
299
+ miravia_ratio=0.3)
300
+ out_path4 = out_dir / "test_v4_auto.html"
301
+ report_v4.render(
302
+ rows=rows4, out_path=out_path4,
303
+ range_label="2026-06-19 ~ 2026-06-21",
304
+ jinja_env=jinja_env, export_columns=EXPORT_COLUMNS,
305
+ default_max_orders=10000,
306
+ )
307
+ print(f" Generated: {out_path4} ({out_path4.stat().st_size / 1024:.1f} KB)")
308
+ print(f" Rows: {len(rows4)}")
309
+
310
+ print("\n" + "=" * 60)
311
+ print("All scenarios completed! Opening in browser...")
312
+ print("=" * 60)
313
+
314
+ # Open the main business report
315
+ import webbrowser
316
+ webbrowser.open(f"file://{out_path}")
317
+
318
+ return 0
319
+
320
+
321
+ if __name__ == "__main__":
322
+ sys.exit(main())
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ OpenAPI Trace Logger
5
+
6
+ Records every Miravia API call with traceId for debugging and tracing.
7
+ Log file: logs/trace-{YYYY-MM-DD}.log (one JSON line per API call)
8
+ Retention: 3 days (auto-cleanup on each write)
9
+
10
+ Fields per entry:
11
+ - time : Request timestamp (ISO format with milliseconds)
12
+ - api : API endpoint path (e.g. /orders/get)
13
+ - params : Business request parameters (excluding sign/token/common fields)
14
+ - traceId : Trace ID returned by the API (may be multiple per session)
15
+ - success : Whether the request was successful (true/false)
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ from datetime import datetime, timedelta
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Optional
23
+
24
+ # Skill root directory / logs
25
+ _SKILL_DIR = Path(__file__).resolve().parent.parent
26
+ _LOGS_DIR = _SKILL_DIR / "logs"
27
+
28
+ # Trace log retention: 5 days
29
+ _TRACE_RETENTION_DAYS = 5
30
+
31
+ _logger = logging.getLogger("miravia.trace_logger")
32
+
33
+
34
+ def _trace_log_path() -> Path:
35
+ """Return today's trace log file path: logs/trace-{YYYY-MM-DD}.log"""
36
+ _LOGS_DIR.mkdir(parents=True, exist_ok=True)
37
+ date_str = datetime.now().strftime("%Y-%m-%d")
38
+ return _LOGS_DIR / f"trace-{date_str}.log"
39
+
40
+
41
+ def _cleanup_old_trace_logs() -> None:
42
+ """Delete trace log files older than _TRACE_RETENTION_DAYS days."""
43
+ if not _LOGS_DIR.exists():
44
+ return
45
+ cutoff = datetime.now() - timedelta(days=_TRACE_RETENTION_DAYS)
46
+ for f in _LOGS_DIR.glob("trace-*.log"):
47
+ try:
48
+ date_part = f.stem.replace("trace-", "")
49
+ file_date = datetime.strptime(date_part, "%Y-%m-%d")
50
+ if file_date < cutoff:
51
+ f.unlink(missing_ok=True)
52
+ except (ValueError, OSError):
53
+ pass
54
+
55
+
56
+ # Common/sensitive keys to exclude from logged params
57
+ _EXCLUDED_PARAM_KEYS = {
58
+ "app_key", "access_token", "sign", "sign_method", "timestamp",
59
+ "skill_name", "action", "agent_type", "model_name",
60
+ }
61
+
62
+
63
+ def record_trace(api: str, trace_id: Optional[str], success: bool,
64
+ params: Optional[Dict[str, Any]] = None) -> None:
65
+ """Write one trace record to logs/trace-{date}.log.
66
+
67
+ Args:
68
+ api: API endpoint path (e.g. "/orders/get")
69
+ trace_id: Trace ID from API response (may be None if not present)
70
+ success: Whether the API call was successful
71
+ params: Request parameters (common/sensitive fields will be filtered out)
72
+ """
73
+ # Filter out common/sensitive params, keep only business params
74
+ biz_params = {}
75
+ if params:
76
+ biz_params = {k: v for k, v in params.items() if k not in _EXCLUDED_PARAM_KEYS}
77
+
78
+ entry = {
79
+ "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3],
80
+ "api": api,
81
+ "params": biz_params,
82
+ "traceId": trace_id or "",
83
+ "success": success,
84
+ }
85
+
86
+ log_path = _trace_log_path()
87
+ try:
88
+ with open(log_path, "a", encoding="utf-8") as f:
89
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
90
+ except OSError as e:
91
+ _logger.warning("Failed to write trace log: %s", e)
92
+
93
+ # Auto-cleanup expired trace logs
94
+ try:
95
+ _cleanup_old_trace_logs()
96
+ except Exception:
97
+ pass
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Post-injection rendering validator for miravia-order-report HTML reports.
4
+
5
+ Validates that injected agentInsights JSON conforms to the frontend schema,
6
+ preventing undefined/broken renders in the browser.
7
+
8
+ Usage:
9
+ python3 scripts/validate_render.py <report_html_path>
10
+
11
+ Exit codes:
12
+ 0 - RENDER_CHECK: OK (all presets valid)
13
+ 1 - FAIL: structural errors detected (details printed to stdout)
14
+ """
15
+
16
+ import json
17
+ import re
18
+ import sys
19
+
20
+
21
+ def validate_report(html_path: str) -> tuple[bool, str]:
22
+ """Validate agentInsights structure in an HTML report.
23
+
24
+ Returns:
25
+ (success: bool, message: str)
26
+ """
27
+ with open(html_path, encoding="utf-8") as f:
28
+ html = f.read()
29
+
30
+ m = re.search(r"const\s+DATA\s*=\s*", html)
31
+ if not m:
32
+ return False, "DATA block not found"
33
+
34
+ start = m.end()
35
+ depth = 0
36
+ i = start
37
+ while i < len(html):
38
+ if html[i] == "{":
39
+ depth += 1
40
+ elif html[i] == "}":
41
+ depth -= 1
42
+ if depth == 0:
43
+ break
44
+ i += 1
45
+
46
+ try:
47
+ data = json.loads(html[start : i + 1])
48
+ except json.JSONDecodeError as e:
49
+ return False, f"DATA JSON parse error: {e}"
50
+
51
+ ai = data.get("agentInsights", {}).get("presets", {})
52
+ if not ai:
53
+ return False, "agentInsights.presets is empty"
54
+
55
+ errors = []
56
+ for pk, preset in ai.items():
57
+ if not isinstance(preset, dict) or not preset.get("summary"):
58
+ errors.append(f"preset {pk}: missing summary")
59
+ continue
60
+ for h in preset.get("highlights", []):
61
+ if not isinstance(h, dict):
62
+ errors.append(f"preset {pk}: highlight is not an object")
63
+ elif "title" not in h or "detail" not in h or "severity" not in h:
64
+ errors.append(f"preset {pk}: highlight missing title/detail/severity")
65
+ elif h["severity"] not in ("high", "medium", "low"):
66
+ errors.append(
67
+ f"preset {pk}: highlight severity '{h['severity']}' invalid (must be high/medium/low)"
68
+ )
69
+ for ac in preset.get("actions", []):
70
+ if not isinstance(ac, dict):
71
+ errors.append(f"preset {pk}: action is not an object")
72
+ elif "text" not in ac or "priority" not in ac:
73
+ errors.append(f"preset {pk}: action missing text/priority")
74
+ elif ac["priority"] not in ("P0", "P1", "P2"):
75
+ errors.append(
76
+ f"preset {pk}: action priority '{ac['priority']}' invalid (must be P0/P1/P2)"
77
+ )
78
+
79
+ if errors:
80
+ return False, "; ".join(errors)
81
+ return True, "OK"
82
+
83
+
84
+ def main():
85
+ if len(sys.argv) < 2:
86
+ print(f"Usage: {sys.argv[0]} <report_html_path>", file=sys.stderr)
87
+ sys.exit(2)
88
+
89
+ html_path = sys.argv[1]
90
+ success, message = validate_report(html_path)
91
+
92
+ if success:
93
+ print(f"RENDER_CHECK: {message}")
94
+ sys.exit(0)
95
+ else:
96
+ print(f"FAIL: {message}")
97
+ sys.exit(1)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()