@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,1694 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """V4 Unified Dashboard Renderer.
4
+
5
+ Receives sub-order granularity rows, computes all dashboard metrics
6
+ and outputs as JSON-injected HTML via Jinja2 template.
7
+
8
+ Data injection: window.DATA = {...}
9
+ Frontend JS handles time-range filtering (slice dailyData) and chart rendering.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import base64
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List
19
+
20
+ from i18n import get_translations, TRANSLATIONS, SUPPORTED_LANGS
21
+
22
+
23
+ # -------- utilities --------
24
+
25
+ def _safe_num(series, fillna=0):
26
+ import pandas as pd
27
+ return pd.to_numeric(series, errors="coerce").fillna(fillna)
28
+
29
+
30
+ # Refund statuses that are inactive/cancelled (should NOT count toward refund amount/rate)
31
+ _CANCELLED_REFUND_STATUSES = {
32
+ "REQUEST_CANCEL", "CANCEL", "CANCELLED", "REJECTED",
33
+ "BUYER_CANCEL", "SELLER_REJECT", "CLOSE", "CLOSED",
34
+ }
35
+
36
+
37
+ def _dedup_items(df):
38
+ """Deduplicate one-to-many refund expansion back to item granularity.
39
+
40
+ After the refund expansion in export_orders._flatten(), a single item with
41
+ N refund records produces N rows. For item-level metrics (GMV, item count,
42
+ cancel rate, AOV), we must count each item only ONCE.
43
+
44
+ Strategy: drop_duplicates by (order_id, order_item_id), keeping first row.
45
+ """
46
+ if df.empty:
47
+ return df
48
+ return df.drop_duplicates(subset=["order_id", "order_item_id"], keep="first")
49
+
50
+
51
+ def _active_refund_rows(df):
52
+ """Get active (non-cancelled) refund rows, deduplicated by refund_id.
53
+
54
+ Used for refund amount and refund count calculations to avoid:
55
+ 1. Double-counting the same refund_id (shouldn't happen but defensive)
56
+ 2. Counting cancelled/rejected refund requests toward loss metrics
57
+ """
58
+ if df.empty:
59
+ return df
60
+ rmask = df["refund_id"].notna() & (df["refund_id"] != "")
61
+ rdf = df[rmask].copy()
62
+ if rdf.empty:
63
+ return rdf
64
+ # Filter out cancelled refund statuses
65
+ if "refund_status" in rdf.columns:
66
+ rdf = rdf[~rdf["refund_status"].fillna("").str.upper().isin(_CANCELLED_REFUND_STATUSES)]
67
+ # Deduplicate by refund_id
68
+ if not rdf.empty:
69
+ rdf = rdf.drop_duplicates(subset=["refund_id"], keep="first")
70
+ return rdf
71
+
72
+
73
+ def _normalize_mp(val) -> str:
74
+ """Normalize marketplace to 'miravia' or 'ae'."""
75
+ if not val:
76
+ return "unknown"
77
+ s = str(val).lower()
78
+ if "miravia" in s:
79
+ return "miravia"
80
+ if "ae" in s or "m2a" in s or "aliexpress" in s:
81
+ return "ae"
82
+ return s
83
+
84
+
85
+ def _common_currency(df) -> str:
86
+ return (df["currency"].dropna().iloc[0]
87
+ if not df.empty and not df["currency"].dropna().empty
88
+ else "EUR")
89
+
90
+
91
+ # -------- country name normalization --------
92
+ # Miravia API applies GDPR masking to ship_country, producing garbled names
93
+ # like "E****a", "S***n", "P******|", "|***y" alongside original names
94
+ # like "España", "Spain", "Portugal", "Italy". This mapping merges all
95
+ # variants of the same country into one canonical name.
96
+
97
+ _KNOWN_COUNTRIES: Dict[str, List[str]] = {
98
+ "Spain": ["spain", "españa", "espana", "espanha"],
99
+ "Portugal": ["portugal"],
100
+ "Italy": ["italy", "italia"],
101
+ "France": ["france", "francia"],
102
+ "Germany": ["germany", "deutschland", "alemania"],
103
+ "United Kingdom": ["united kingdom", "uk", "gran bretaña"],
104
+ "Poland": ["poland", "polonia"],
105
+ "Netherlands": ["netherlands", "países bajos", "holanda", "paesi bassi"],
106
+ "Belgium": ["belgium", "bélgica", "belgio", "belgien"],
107
+ "Ireland": ["ireland", "irlanda"],
108
+ "Austria": ["austria"],
109
+ "Sweden": ["sweden", "suecia", "svezia", "schweden"],
110
+ "Denmark": ["denmark", "dinamarca", "danimarca", "dänemark"],
111
+ "Finland": ["finland", "finlandia"],
112
+ "Norway": ["norway", "noruega", "norvegia", "norwegen"],
113
+ "Switzerland": ["switzerland", "suiza", "svizzera", "schweiz"],
114
+ "Greece": ["greece", "grecia"],
115
+ "Czech Republic": ["czech republic", "chequia", "repubblica ceca"],
116
+ "Romania": ["romania", "rumanía"],
117
+ "Hungary": ["hungary", "hungría", "ungheria", "ungarn"],
118
+ "Bulgaria": ["bulgaria"],
119
+ "Croatia": ["croatia", "croacia", "croazia"],
120
+ "Slovakia": ["slovakia", "eslovaquia", "slovacchia"],
121
+ "Slovenia": ["slovenia", "eslovenia"],
122
+ "Luxembourg": ["luxembourg", "luxemburgo", "lussemburgo"],
123
+ "Latvia": ["latvia", "letonia"],
124
+ "Estonia": ["estonia"],
125
+ "Lithuania": ["lithuania", "lituania"],
126
+ }
127
+
128
+ # Pre-build reverse lookup: lowercase variant -> canonical name
129
+ _COUNTRY_LOOKUP: Dict[str, str] = {}
130
+ for _canon, _variants in _KNOWN_COUNTRIES.items():
131
+ for _v in _variants:
132
+ _COUNTRY_LOOKUP[_v.lower()] = _canon
133
+ _COUNTRY_LOOKUP[_canon.lower()] = _canon
134
+
135
+
136
+ def _normalize_country(val) -> str:
137
+ """Normalize country names including GDPR masked variants and language variants.
138
+
139
+ Miravia API applies GDPR masking using * and | as mask characters:
140
+ 'E****a' -> 'Spain' (middle chars masked with *)
141
+ 'p******|' -> 'Portugal' (last char masked with |)
142
+ '|***y' -> 'Italy' (first char masked with |)
143
+ 'S***n' -> 'Spain' (middle chars masked with *)
144
+
145
+ Both mask characters are replaced with regex wildcards for matching.
146
+ """
147
+ if not val or not str(val).strip():
148
+ return "Unknown"
149
+ raw = str(val).strip()
150
+ key = raw.lower()
151
+
152
+ # 1. Direct lookup (handles plain names in EN/ES/IT/DE/PT)
153
+ if key in _COUNTRY_LOOKUP:
154
+ return _COUNTRY_LOOKUP[key]
155
+
156
+ # 2. De-mask: if contains * or |, build regex and match against known names
157
+ if "*" in raw or "|" in raw:
158
+ escaped = re.escape(raw)
159
+ # Replace escaped asterisks and pipes with wildcard dots
160
+ pattern_str = escaped.replace(r"\*", ".").replace(r"\|", ".")
161
+ pattern = re.compile(f"^{pattern_str}$", re.IGNORECASE)
162
+ for variants in _KNOWN_COUNTRIES.values():
163
+ for variant in variants:
164
+ if pattern.match(variant):
165
+ return _COUNTRY_LOOKUP[variant.lower()]
166
+
167
+ return raw
168
+
169
+
170
+ # -------- category classification --------
171
+
172
+ _CATEGORY_KEYWORDS = [
173
+ ("Home & Living", ["lamp", "led", "alfombra", "cortina",
174
+ "cojín", "mueble", "organiza", "decoración"]),
175
+ ("Electronics", ["auriculares", "bluetooth", "altavoz",
176
+ "reloj inteligente", "cargador", "móvil",
177
+ "tablet", "electrónic"]),
178
+ ("Kitchen", ["sartén", "cookware", "cuchillo", "cafetera",
179
+ "cocina", "kitchen", "kitchenware"]),
180
+ ("Sports & Outdoors", ["mochila", "bicicleta", "patinete",
181
+ "deporte", "fitness", "outdoor", "balón"]),
182
+ ("Beauty & Personal Care", ["crema", "maquillaje", "champú",
183
+ "perfume", "uña", "cuidado"]),
184
+ ("Fashion & Accessories", ["ropa", "camiseta", "pantalón", "vestido",
185
+ "chaqueta", "zapato", "bolso"]),
186
+ ("Baby & Toys", ["bebé", "niño", "juguetes", "embarazo"]),
187
+ ("Auto Parts", ["coche", "car ", "auto", "moto"]),
188
+ ]
189
+
190
+
191
+ # Category internal label -> i18n key mapping
192
+ _CATEGORY_KEY_MAP = {
193
+ "Home & Living": "cat_home", "Electronics": "cat_digital", "Kitchen": "cat_kitchen",
194
+ "Sports & Outdoors": "cat_sports", "Beauty & Personal Care": "cat_beauty", "Fashion & Accessories": "cat_fashion",
195
+ "Baby & Toys": "cat_baby", "Auto Parts": "cat_auto",
196
+ }
197
+
198
+
199
+ def _classify_category(name, t=None) -> str:
200
+ if not name:
201
+ return t["cat_other"] if t else "Other"
202
+ s = str(name).lower()
203
+ for cat, keywords in _CATEGORY_KEYWORDS:
204
+ for kw in keywords:
205
+ if kw.lower() in s:
206
+ if t:
207
+ return t.get(_CATEGORY_KEY_MAP.get(cat, "cat_other"), cat)
208
+ return cat
209
+ return t["cat_other"] if t else "Other"
210
+
211
+
212
+ def _check_buyer_id_available(df) -> bool:
213
+ """Check if buyer_id field has any non-empty values in the dataframe.
214
+
215
+ When buyer_id is unavailable (e.g., invite-test token), the 'bu' metric
216
+ is set to 0 (no reliable buyer data). This flag lets the frontend
217
+ display 'N/A' or hide the buyer count section to avoid misleading users.
218
+ """
219
+ if df.empty:
220
+ return True # Default: assume available (no data to check)
221
+ buyer_ids = df["buyer_id"].dropna()
222
+ buyer_ids = buyer_ids[buyer_ids != ""]
223
+ return len(buyer_ids) > 0
224
+
225
+
226
+ # -------- daily data --------
227
+
228
+ def _build_daily_data(df, range_start: str = None, range_end: str = None) -> List[Dict]:
229
+ """Build dailyData array with per-platform breakdown.
230
+
231
+ Each entry: {ds, mO, aO, mI, aI, mG, aG, mR, aR, mC, aC, bu, rAmt, mRA, aRA}
232
+
233
+ - mO/aO: unique order count (for AOV denominator)
234
+ - mI/aI: item (sub-order) count (for cancel/refund rate denominator)
235
+ - mR/aR, mC/aC: item-level refund/cancel counts
236
+ - mRA/aRA: per-platform refund amounts
237
+
238
+ Deduplication: item-level metrics use deduplicated rows (one per item);
239
+ refund metrics use active refunds only (deduplicated by refund_id).
240
+
241
+ Generates entries for ALL calendar days in the query range (including
242
+ days with zero orders) so that DAYS = dailyData.length always matches
243
+ the actual calendar span.
244
+ """
245
+ import pandas as pd
246
+ if df.empty:
247
+ # Even with no data, fill the full date range with zeros
248
+ if range_start and range_end:
249
+ start = pd.to_datetime(range_start, errors="coerce")
250
+ end = pd.to_datetime(range_end, errors="coerce")
251
+ if pd.notna(start) and pd.notna(end):
252
+ all_dates = pd.date_range(start.normalize(), end.normalize(), freq="D")
253
+ return [{"ds": str(d.date()), "mO": 0, "aO": 0, "mI": 0, "aI": 0,
254
+ "mG": 0, "aG": 0, "mR": 0, "aR": 0, "mC": 0, "aC": 0,
255
+ "bu": 0, "rAmt": 0, "mRA": 0, "aRA": 0}
256
+ for d in all_dates]
257
+ return []
258
+
259
+ # Deduplicate for item-level metrics (GMV, item count, cancel)
260
+ df_items = _dedup_items(df)
261
+ s = df_items.copy()
262
+ s["_d"] = pd.to_datetime(s["created_at"], errors="coerce").dt.date
263
+ s["_paid"] = _safe_num(s["buyer_paid"])
264
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
265
+ s["_is_cancel"] = s["is_canceled"].fillna(False).astype(bool)
266
+ s = s.dropna(subset=["_d"])
267
+ if s.empty:
268
+ return []
269
+
270
+ # Active refunds for refund metrics (deduplicated by refund_id, exclude cancelled)
271
+ df_refunds = _active_refund_rows(df)
272
+ r = df_refunds.copy() if not df_refunds.empty else pd.DataFrame()
273
+ if not r.empty:
274
+ r["_d"] = pd.to_datetime(r["created_at"], errors="coerce").dt.date
275
+ r["_mp"] = r["marketplace"].apply(_normalize_mp)
276
+ r["_refund_amt"] = _safe_num(r["refund_amount"])
277
+ r = r.dropna(subset=["_d"])
278
+
279
+ # Build lookup of per-day stats
280
+ day_map: Dict[str, Dict] = {}
281
+ for d, dg in s.groupby("_d"):
282
+ mir = dg[dg["_mp"] == "miravia"]
283
+ ae = dg[dg["_mp"] == "ae"]
284
+ buyer_ids = dg["buyer_id"].dropna()
285
+ buyer_ids = buyer_ids[buyer_ids != ""]
286
+ bu = int(buyer_ids.nunique()) if len(buyer_ids) > 0 else 0
287
+ # Refund stats for this day from active refunds
288
+ if not r.empty:
289
+ r_day = r[r["_d"] == d]
290
+ r_mir = r_day[r_day["_mp"] == "miravia"]
291
+ r_ae = r_day[r_day["_mp"] == "ae"]
292
+ mR = len(r_mir)
293
+ aR = len(r_ae)
294
+ rAmt = round(float(r_day["_refund_amt"].sum()), 2)
295
+ mRA = round(float(r_mir["_refund_amt"].sum()), 2)
296
+ aRA = round(float(r_ae["_refund_amt"].sum()), 2)
297
+ else:
298
+ mR = aR = 0
299
+ rAmt = mRA = aRA = 0
300
+ day_map[str(d)] = {
301
+ "ds": str(d),
302
+ "mO": int(mir["order_id"].nunique()),
303
+ "aO": int(ae["order_id"].nunique()),
304
+ "mI": len(mir),
305
+ "aI": len(ae),
306
+ "mG": round(float(_safe_num(mir["buyer_paid"]).sum()), 2),
307
+ "aG": round(float(_safe_num(ae["buyer_paid"]).sum()), 2),
308
+ "mR": mR,
309
+ "aR": aR,
310
+ "mC": int(mir["_is_cancel"].sum()),
311
+ "aC": int(ae["_is_cancel"].sum()),
312
+ "bu": bu,
313
+ "rAmt": rAmt,
314
+ "mRA": mRA,
315
+ "aRA": aRA,
316
+ }
317
+
318
+ # Determine full date range (prefer explicit range params, fallback to data min/max)
319
+ if range_start and range_end:
320
+ start = pd.to_datetime(range_start, errors="coerce").normalize()
321
+ end = pd.to_datetime(range_end, errors="coerce").normalize()
322
+ else:
323
+ dates = pd.to_datetime(s["created_at"], errors="coerce")
324
+ start = dates.min().normalize()
325
+ end = dates.max().normalize()
326
+
327
+ all_dates = pd.date_range(start, end, freq="D")
328
+ zero_entry = {"mO": 0, "aO": 0, "mI": 0, "aI": 0, "mG": 0, "aG": 0,
329
+ "mR": 0, "aR": 0, "mC": 0, "aC": 0, "bu": 0, "rAmt": 0,
330
+ "mRA": 0, "aRA": 0}
331
+
332
+ daily = []
333
+ for d in all_dates:
334
+ ds = str(d.date())
335
+ if ds in day_map:
336
+ daily.append(day_map[ds])
337
+ else:
338
+ daily.append({"ds": ds, **zero_entry})
339
+
340
+ return daily
341
+
342
+
343
+ # -------- static distributions --------
344
+
345
+ def _build_skus(df, n=20, t=None) -> List[Dict]:
346
+ """Top SKUs with per-platform sales and refund rates.
347
+
348
+ Uses deduplicated item rows for quantity/price, and active refunds for refund metrics.
349
+ """
350
+ if df.empty:
351
+ return []
352
+ # Item-level: deduplicate to avoid counting same item multiple times
353
+ s = _dedup_items(df).copy()
354
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
355
+ s["quantity"] = _safe_num(s["quantity"])
356
+ s["_paid"] = _safe_num(s["buyer_paid"])
357
+ # Mark items that have at least one active refund
358
+ active_refund_items = set()
359
+ df_refunds = _active_refund_rows(df)
360
+ if not df_refunds.empty:
361
+ active_refund_items = set(df_refunds["order_item_id"].dropna().unique())
362
+ s["_has_active_refund"] = s["order_item_id"].isin(active_refund_items).astype(int)
363
+
364
+ # Active refund amounts per item (summed if multiple active refunds)
365
+ refund_amt_by_item: Dict[str, float] = {}
366
+ if not df_refunds.empty:
367
+ for iid, g in df_refunds.groupby("order_item_id"):
368
+ refund_amt_by_item[str(iid)] = float(_safe_num(g["refund_amount"]).sum())
369
+ s["_refund_amt"] = s["order_item_id"].map(lambda x: refund_amt_by_item.get(str(x), 0))
370
+
371
+ results = []
372
+ for (sku, name), g in s.groupby(["sku", "product_name"], dropna=False):
373
+ mir = g[g["_mp"] == "miravia"]
374
+ ae = g[g["_mp"] == "ae"]
375
+ mq = int(mir["quantity"].sum())
376
+ aq = int(ae["quantity"].sum())
377
+ ip_vals = _safe_num(g["unit_price"])
378
+ pp_vals = _safe_num(g["buyer_paid"])
379
+ refund_count = int(g["_has_active_refund"].sum())
380
+ rr = refund_count / len(g) if len(g) > 0 else 0
381
+ mRA = round(float(mir["_refund_amt"].sum()), 2) if not mir.empty else 0
382
+ aRA = round(float(ae["_refund_amt"].sum()), 2) if not ae.empty else 0
383
+ results.append({
384
+ "s": str(sku) if sku else "-",
385
+ "nm": str(name) if name else "-",
386
+ "mq": mq, "aq": aq,
387
+ "ip": round(float(ip_vals.iloc[0]) if len(ip_vals) > 0 else 0, 2),
388
+ "pp": round(float(pp_vals.iloc[0]) if len(pp_vals) > 0 else 0, 2),
389
+ "rr": round(rr, 4),
390
+ "mRA": mRA, "aRA": aRA,
391
+ "cat": _classify_category(name, t=t),
392
+ })
393
+ results.sort(key=lambda x: -(x["mq"] + x["aq"]))
394
+ return results[:n]
395
+
396
+
397
+ def _normalize_city_series(series):
398
+ """Normalize GDPR-masked city names by matching against clean values in the same dataset.
399
+
400
+ Strategy: collect all non-masked city names, then for each masked city build a regex
401
+ from its visible chars (treating * and | as wildcards) and try to match a clean
402
+ counterpart. If matched → merge into the clean name; if not → keep the masked
403
+ value as-is so it still participates in statistics.
404
+
405
+ Examples (given 'Madrid' also exists in the dataset):
406
+ 'M****d' -> 'Madrid' (regex ^M....d$ matches)
407
+ '**********|' -> unchanged (no 11-char clean city matches)
408
+ 'A**a Abla' -> unchanged (no match found)
409
+ """
410
+ # Collect all clean (non-masked) city names from this dataset
411
+ clean_names = set()
412
+ for val in series:
413
+ v = str(val).strip() if val else ""
414
+ if v and not re.search(r'[*|]', v):
415
+ clean_names.add(v)
416
+
417
+ if not clean_names:
418
+ return series # No clean names to match against
419
+
420
+ # Build a cache for masked -> clean mappings to avoid repeated regex work
421
+ cache: Dict[str, str] = {}
422
+
423
+ def _normalize_one(val):
424
+ if not val or not str(val).strip():
425
+ return val
426
+ raw = str(val).strip()
427
+
428
+ # Not masked -> return as-is
429
+ if not re.search(r'[*|]', raw):
430
+ return raw
431
+
432
+ # Check cache
433
+ if raw in cache:
434
+ return cache[raw]
435
+
436
+ # Build regex from masked value
437
+ escaped = re.escape(raw)
438
+ pattern_str = escaped.replace(r"\*", ".").replace(r"\|", ".")
439
+ try:
440
+ pattern = re.compile(f"^{pattern_str}$", re.IGNORECASE)
441
+ except re.error:
442
+ cache[raw] = raw
443
+ return raw
444
+
445
+ # Try to match against clean city names in the same dataset
446
+ for clean in clean_names:
447
+ if pattern.match(clean):
448
+ cache[raw] = clean
449
+ return clean
450
+
451
+ # No match found - keep masked value (still participates in stats)
452
+ cache[raw] = raw
453
+ return raw
454
+
455
+ return series.apply(_normalize_one)
456
+
457
+
458
+ def _build_cities(df, n=10) -> List[Dict]:
459
+ """City distribution with deduplication."""
460
+ if df.empty:
461
+ return []
462
+ s = _dedup_items(df).copy()
463
+ s["city"] = s["city"].fillna("")
464
+ s = s[s["city"] != ""]
465
+ if s.empty:
466
+ return []
467
+ # Normalize GDPR-masked cities: merge with clean counterparts where possible
468
+ s["city"] = _normalize_city_series(s["city"])
469
+ g = s.groupby("city").size().reset_index(name="o")
470
+ g = g.sort_values("o", ascending=False).head(n)
471
+ return [{"n": r["city"], "o": int(r["o"])} for _, r in g.iterrows()]
472
+
473
+
474
+ def _build_carriers(df, n=15) -> List[Dict]:
475
+ """Carrier stats with deduplication for accurate refund/cancel rates."""
476
+ if df.empty:
477
+ return []
478
+ # Deduplicate for item-level counts
479
+ s = _dedup_items(df).copy()
480
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
481
+ s["shipping_provider"] = s["shipping_provider"].fillna("Unknown")
482
+ s["_is_cancel"] = s["is_canceled"].fillna(False).astype(bool)
483
+ # Mark items with active refund
484
+ active_refund_items = set()
485
+ df_refunds = _active_refund_rows(df)
486
+ if not df_refunds.empty:
487
+ active_refund_items = set(df_refunds["order_item_id"].dropna().unique())
488
+ s["_has_refund"] = s["order_item_id"].isin(active_refund_items)
489
+
490
+ results = []
491
+ for carrier, g in s.groupby("shipping_provider"):
492
+ mir = g[g["_mp"] == "miravia"]
493
+ ae = g[g["_mp"] == "ae"]
494
+ total = len(g)
495
+ cr = float(g["_is_cancel"].sum()) / total if total else 0
496
+ rr = float(g["_has_refund"].sum()) / total if total else 0
497
+ results.append({
498
+ "nm": carrier, "mo": len(mir), "ao": len(ae),
499
+ "cr": round(cr, 4), "rr": round(rr, 4),
500
+ })
501
+ results.sort(key=lambda x: -(x["mo"] + x["ao"]))
502
+ return results[:n]
503
+
504
+
505
+ # -------- refund reason classification --------
506
+
507
+ _REASON_CATEGORIES = {
508
+ "system_error": [
509
+ "fallo en el sistema", "system error", "system failure",
510
+ "errore di sistema", "errore del sistema", "guasto del sistema",
511
+ ],
512
+ "out_of_stock": [
513
+ "no hay stock", "out of stock", "out_of_stock", "fuera de stock",
514
+ "sin stock", "esaurito", "non disponibile", "overtime_close_order",
515
+ ],
516
+ "preparation_delay": [
517
+ "demora", "preparación del pedido", "preparation delay",
518
+ "ritardo preparazione", "ritardo nella preparazione",
519
+ ],
520
+ "seller_unresponsive": [
521
+ "seller did not respond", "vendedor no respondió", "no respond",
522
+ "venditore non ha risposto", "sin respuesta del vendedor",
523
+ ],
524
+ "shipping_delay": [
525
+ "no llegará tan rápido", "delivery too slow", "shipping delay", "tarda",
526
+ "slow delivery", "late", "retraso", "ritardo consegna", "consegna lenta",
527
+ "llegará tarde", "tiempo de entrega", "envío lento", "not arrive",
528
+ "didn't arrive", "not received", "no recibido", "non ricevuto",
529
+ "no he recibido", "non ho ricevuto",
530
+ ],
531
+ "wrong_item": [
532
+ "wrong item", "artículo incorrecto", "recibido no es correcto",
533
+ "producto equivocado", "prodotto sbagliato", "sent wrong",
534
+ "articolo errato", "item received is not",
535
+ ],
536
+ "missing_parts": [
537
+ "faltan partes", "missing parts", "accesorios", "accessories",
538
+ "parti mancanti", "incompleto",
539
+ ],
540
+ "quantity_mismatch": [
541
+ "más o menos artículos", "más/menos", "más o menos",
542
+ "quantity", "missing item",
543
+ "più o meno articoli", "received more or less", "cantidad incorrecta",
544
+ "pezzi mancanti", "fewer items", "more items",
545
+ ],
546
+ "quality_issue": [
547
+ "damaged", "dañado", "roto", "broken", "defect", "quality",
548
+ "danneggiato", "paquete dañ", "artículo y paquete dañ",
549
+ "daños", "defectuoso", "difettoso", "calidad",
550
+ ],
551
+ "description_mismatch": [
552
+ "description", "no coincide", "not as described", "inaccurate",
553
+ "descripción", "non corrisponde", "foto", "imagen", "tamaño",
554
+ "size", "color", "different from", "not match",
555
+ "precio del artículo", "error en el precio",
556
+ "not as expected", "no es lo esperado",
557
+ ],
558
+ "buyer_regret": [
559
+ "no longer need", "don't need", "ya no necesito", "changed mind",
560
+ "no need", "no lo necesito", "i don't need",
561
+ "non ho più bisogno", "cambio idea", "found cheaper",
562
+ "encontrado más barato", "barato en otro sitio",
563
+ "do not need", "don't want", "no lo quiero",
564
+ "no necesito", "precio mejor", "solicitud de cancelación",
565
+ "método de pago", "payment method",
566
+ ],
567
+ "customs_issue": [
568
+ "customs", "aduana", "held at customs", "retenido", "dogana",
569
+ ],
570
+ "logistics_issue": [
571
+ "lost", "tracking", "returned", "lost in transit", "carrier",
572
+ "devuelto", "perdido", "tracking shows", "paquete perdido",
573
+ "smarrito", "reso", "pérdida", "punto de recogida",
574
+ "empresa de mensajería", "accidente",
575
+ ],
576
+ "shipping_method": [
577
+ "cambiar el método de envío", "change shipping", "metodo di spedizione",
578
+ "cambiar la dirección", "change address", "indirizzo",
579
+ "problema con la dirección", "dirección de entrega",
580
+ ],
581
+ "refund_agreement": [
582
+ "reembolso acordado", "acordado con el vendedor", "agreement",
583
+ "accordo", "refund agreed", "acordado", "acordado con la tienda",
584
+ ],
585
+ }
586
+
587
+
588
+ def _classify_refund_reason(reason_text: str) -> str:
589
+ """Classify refund reason text into a category for differentiated advice."""
590
+ if not reason_text:
591
+ return "unknown"
592
+ lower = reason_text.lower()
593
+ for category, keywords in _REASON_CATEGORIES.items():
594
+ for kw in keywords:
595
+ if kw in lower:
596
+ return category
597
+ return "unknown"
598
+
599
+
600
+ def _generate_smart_rec(label: str, category: str, top_skus: List[str],
601
+ carrier_str: str, dp: str, pc: float, amt: float,
602
+ currency: str, t=None) -> str:
603
+ """Generate data-driven, contextual recommendation for a refund reason."""
604
+ pct_str = f"{pc * 100:.1f}%"
605
+ amt_str = f"{amt:.2f} {currency}"
606
+ sku_mention = top_skus[0] if top_skus else ""
607
+ carrier_mention = carrier_str if carrier_str != "N/A" else ""
608
+
609
+ # Use i18n templates if available
610
+ if t:
611
+ tpl_key = f"rec_{category}"
612
+ tpl = t.get(tpl_key)
613
+ if tpl:
614
+ return tpl.format(
615
+ reason=label, pct=pct_str, amt=amt_str,
616
+ sku=sku_mention, carrier=carrier_mention, duty=dp,
617
+ )
618
+ # Fallback to generic with data
619
+ return t.get("rec_unknown", "").format(
620
+ reason=label, pct=pct_str, amt=amt_str,
621
+ sku=sku_mention, carrier=carrier_mention, duty=dp,
622
+ ) or f"For '{label}' refunds ({pct_str}, total {amt_str}), recommend checking related SKUs and logistics providers to optimize after-sales process."
623
+
624
+ # No i18n fallback (should not happen in practice)
625
+ return f"For '{label}' refunds ({pct_str}, total {amt_str}), recommend checking related SKUs and logistics providers to optimize after-sales process."
626
+
627
+
628
+ def _build_ref_reasons(df, currency, t=None) -> List[Dict]:
629
+ """Refund reason breakdown using active refunds only."""
630
+ if df.empty:
631
+ return []
632
+ # Use active refunds only (exclude cancelled)
633
+ rdf = _active_refund_rows(df)
634
+ if rdf.empty:
635
+ return []
636
+ rdf = rdf.copy()
637
+ rdf["_amt"] = _safe_num(rdf["refund_amount"])
638
+ total = len(rdf)
639
+
640
+ results = []
641
+ for reason, g in rdf.groupby("refund_reason", dropna=False):
642
+ label = reason if reason else (t["ref_unspecified"] if t else "Unspecified")
643
+ pc = len(g) / total if total else 0
644
+ amt = float(g["_amt"].sum())
645
+ dp_counts = g["duty_party"].fillna("pending").value_counts()
646
+ dp = str(dp_counts.index[0]) if len(dp_counts) > 0 else "pending"
647
+ top_skus = [str(x) for x in g["sku"].dropna().unique()[:3]]
648
+ carriers = g["shipping_provider"].dropna().unique()
649
+ carrier_str = ", ".join(str(c) for c in carriers[:3]) if len(carriers) > 0 else "N/A"
650
+ ft = ", ".join(top_skus[:2]) if top_skus else "-"
651
+ category = _classify_refund_reason(str(label))
652
+ rec = _generate_smart_rec(
653
+ label, category, top_skus, carrier_str, dp, pc, amt,
654
+ currency if currency else "EUR", t=t,
655
+ )
656
+ results.append({
657
+ "r": str(label),
658
+ "pc": round(pc, 4),
659
+ "amt": round(amt, 2),
660
+ "dp": dp,
661
+ "ft": ft,
662
+ "detail": {
663
+ "skus": top_skus,
664
+ "trend": t["ref_detail_trend"] if t else "Statistics within data period",
665
+ "carriers": carrier_str,
666
+ "rec": rec,
667
+ },
668
+ })
669
+ results.sort(key=lambda x: -x["pc"])
670
+ return results
671
+
672
+
673
+ def _build_ab_buyers(df) -> List[Dict]:
674
+ if df.empty:
675
+ return []
676
+ rmask = df["refund_id"].notna() & (df["refund_id"] != "")
677
+ rdf = df[rmask].copy()
678
+ if rdf.empty:
679
+ return []
680
+ rdf["_buyer"] = rdf["buyer_id"].fillna("").astype(str)
681
+ rdf.loc[rdf["_buyer"] == "", "_buyer"] = rdf["buyer_name"].fillna("").astype(str)
682
+ rdf = rdf[rdf["_buyer"] != ""]
683
+ if rdf.empty:
684
+ return []
685
+
686
+ df2 = df.copy()
687
+ df2["_buyer"] = df2["buyer_id"].fillna("").astype(str)
688
+ df2.loc[df2["_buyer"] == "", "_buyer"] = df2["buyer_name"].fillna("").astype(str)
689
+ df2 = df2[df2["_buyer"] != ""]
690
+ if df2.empty:
691
+ return []
692
+
693
+ total_orders = df2.groupby("_buyer")["order_id"].nunique()
694
+ refund_orders = rdf.groupby("_buyer")["order_id"].nunique()
695
+
696
+ results = []
697
+ for buyer in refund_orders.index:
698
+ o = int(total_orders.get(buyer, 0))
699
+ rf = int(refund_orders[buyer])
700
+ if o < 3:
701
+ continue
702
+ rt = rf / o if o > 0 else 0
703
+ risk = "high" if rt > 0.30 else "mid" if rt > 0.20 else "low"
704
+ results.append({"id": buyer, "o": o, "rf": rf, "rt": round(rt, 4), "risk": risk})
705
+ results.sort(key=lambda x: -x["rt"])
706
+ return results[:20]
707
+
708
+
709
+ def _build_countries(df) -> List[Dict]:
710
+ """Country breakdown with deduplication for accurate GMV and refund rates."""
711
+ if df.empty:
712
+ return []
713
+ # Deduplicate for item-level GMV
714
+ s = _dedup_items(df).copy()
715
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
716
+ s["_paid"] = _safe_num(s["buyer_paid"])
717
+ s["ship_country"] = s["ship_country"].fillna("Unknown").apply(_normalize_country)
718
+ # Mark items with active refund
719
+ active_refund_items = set()
720
+ df_refunds = _active_refund_rows(df)
721
+ if not df_refunds.empty:
722
+ active_refund_items = set(df_refunds["order_item_id"].dropna().unique())
723
+ s["_has_refund"] = s["order_item_id"].isin(active_refund_items)
724
+
725
+ results = []
726
+ for country, g in s.groupby("ship_country"):
727
+ mir = g[g["_mp"] == "miravia"]
728
+ ae = g[g["_mp"] == "ae"]
729
+ refund_count = int(g["_has_refund"].sum())
730
+ rr = refund_count / len(g) if len(g) > 0 else 0
731
+ results.append({
732
+ "nm": country, "cd": country,
733
+ "all_gmv": round(float(g["_paid"].sum()), 2),
734
+ "miravia_gmv": round(float(mir["_paid"].sum()), 2),
735
+ "ae_gmv": round(float(ae["_paid"].sum()), 2),
736
+ "all_orders": int(g["order_id"].nunique()),
737
+ "miravia_orders": int(mir["order_id"].nunique()),
738
+ "ae_orders": int(ae["order_id"].nunique()),
739
+ "rr": round(rr, 4),
740
+ })
741
+ results.sort(key=lambda x: -x["all_gmv"])
742
+ return results
743
+
744
+
745
+ def _build_pay_country(df) -> Dict:
746
+ import pandas as pd
747
+ if df.empty:
748
+ return {"countries": [], "payments": [], "data": []}
749
+ unique = df.drop_duplicates("order_id").copy()
750
+ unique["ship_country"] = unique["ship_country"].fillna("Unknown").apply(_normalize_country)
751
+ unique["payment_method"] = unique["payment_method"].fillna("Unknown")
752
+ top_countries = unique["ship_country"].value_counts().head(5).index.tolist()
753
+ top_payments = unique["payment_method"].value_counts().head(5).index.tolist()
754
+ pivot = pd.crosstab(
755
+ unique["ship_country"], unique["payment_method"], normalize="index"
756
+ ) * 100
757
+ data = []
758
+ for country in top_countries:
759
+ row = {"country": country}
760
+ for pm in top_payments:
761
+ row[pm] = round(float(pivot.loc[country, pm]), 1) if country in pivot.index and pm in pivot.columns else 0
762
+ data.append(row)
763
+ return {"countries": top_countries, "payments": top_payments, "data": data}
764
+
765
+
766
+ # -------- platform-split distributions --------
767
+
768
+ def _build_dist_by_platform(df, column, fillna="Unknown", n=10) -> Dict:
769
+ """Generic distribution by platform for a given column."""
770
+ if df.empty:
771
+ return {"all": [], "miravia": [], "ae": []}
772
+ s = df.copy()
773
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
774
+ s[column] = s[column].fillna(fillna)
775
+
776
+ def _dist(sub):
777
+ if sub.empty:
778
+ return []
779
+ counts = sub[column].value_counts().head(n)
780
+ total = int(counts.sum()) or 1
781
+ return [{"name": str(k), "count": int(v), "pct": round(v / total * 100, 1)}
782
+ for k, v in counts.items()]
783
+
784
+ return {
785
+ "all": _dist(s),
786
+ "miravia": _dist(s[s["_mp"] == "miravia"]),
787
+ "ae": _dist(s[s["_mp"] == "ae"]),
788
+ }
789
+
790
+
791
+ def _build_cancel_initiator(df) -> Dict:
792
+ if df.empty:
793
+ return {"all": [], "miravia": [], "ae": []}
794
+ s = df.copy()
795
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
796
+ cdf = s[s["is_canceled"].fillna(False).astype(bool)]
797
+
798
+ def _dist(sub):
799
+ if sub.empty:
800
+ return []
801
+ counts = sub["cancel_initiator"].fillna("Unknown").value_counts()
802
+ total = int(counts.sum()) or 1
803
+ return [{"name": str(k), "count": int(v), "pct": round(v / total * 100, 1)}
804
+ for k, v in counts.items()]
805
+
806
+ return {
807
+ "all": _dist(cdf),
808
+ "miravia": _dist(cdf[cdf["_mp"] == "miravia"]),
809
+ "ae": _dist(cdf[cdf["_mp"] == "ae"]),
810
+ }
811
+
812
+
813
+ def _build_cancel_agree(df) -> Dict:
814
+ if df.empty:
815
+ return {"all": {"agreed": 0, "rejected": 0},
816
+ "miravia": {"agreed": 0, "rejected": 0},
817
+ "ae": {"agreed": 0, "rejected": 0}}
818
+ s = df.copy()
819
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
820
+
821
+ def _rate(sub):
822
+ has_data = sub["seller_agreed"].notna()
823
+ if not has_data.any():
824
+ return {"agreed": 0, "rejected": 0}
825
+ agreed = int((has_data & sub["seller_agreed"].astype(bool)).sum())
826
+ rejected = int((has_data & ~sub["seller_agreed"].astype(bool)).sum())
827
+ total = agreed + rejected
828
+ if total == 0:
829
+ return {"agreed": 0, "rejected": 0}
830
+ return {"agreed": round(agreed / total * 100), "rejected": round(rejected / total * 100)}
831
+
832
+ return {
833
+ "all": _rate(s),
834
+ "miravia": _rate(s[s["_mp"] == "miravia"]),
835
+ "ae": _rate(s[s["_mp"] == "ae"]),
836
+ }
837
+
838
+
839
+ def _build_refund_types(df) -> Dict:
840
+ if df.empty:
841
+ return {"all": [], "miravia": [], "ae": []}
842
+ rmask = df["refund_id"].notna() & (df["refund_id"] != "")
843
+ rdf = df[rmask].copy()
844
+ if rdf.empty:
845
+ return {"all": [], "miravia": [], "ae": []}
846
+ rdf["_mp"] = rdf["marketplace"].apply(_normalize_mp)
847
+ rdf["request_type"] = rdf["request_type"].fillna("UNKNOWN")
848
+ return _build_dist_by_platform(rdf, "request_type", "UNKNOWN")
849
+
850
+
851
+ def _build_duty_party(df) -> Dict:
852
+ if df.empty:
853
+ return {"all": [], "miravia": [], "ae": []}
854
+ rmask = df["refund_id"].notna() & (df["refund_id"] != "")
855
+ rdf = df[rmask].copy()
856
+ if rdf.empty:
857
+ return {"all": [], "miravia": [], "ae": []}
858
+ rdf["duty_party"] = rdf["duty_party"].fillna("pending")
859
+ return _build_dist_by_platform(rdf, "duty_party", "pending")
860
+
861
+
862
+ def _build_fast_refund(df) -> Dict:
863
+ if df.empty:
864
+ return {"all": [], "miravia": [], "ae": []}
865
+ rmask = df["refund_id"].notna() & (df["refund_id"] != "")
866
+ rdf = df[rmask].copy()
867
+ if rdf.empty:
868
+ return {"all": [], "miravia": [], "ae": []}
869
+ rdf["_mp"] = rdf["marketplace"].apply(_normalize_mp)
870
+ rdf["_amt"] = _safe_num(rdf["refund_amount"])
871
+
872
+ def _stats(sub):
873
+ if sub.empty:
874
+ return []
875
+ t = len(sub) or 1
876
+ fast = int(sub["is_fast_refund"].fillna(False).astype(bool).sum())
877
+ instant = int(sub["is_instant_refund"].fillna(False).astype(bool).sum())
878
+ small = int((sub["_amt"] < 50).sum())
879
+ large = int((sub["_amt"] >= 200).sum())
880
+ return [
881
+ {"name": "Fast Refund", "count": fast, "pct": round(fast / t * 100, 1)},
882
+ {"name": "Instant Refund", "count": instant, "pct": round(instant / t * 100, 1)},
883
+ {"name": "Small Refund(<50)", "count": small, "pct": round(small / t * 100, 1)},
884
+ {"name": "Large Refund(>=200)", "count": large, "pct": round(large / t * 100, 1)},
885
+ ]
886
+
887
+ return {
888
+ "all": _stats(rdf),
889
+ "miravia": _stats(rdf[rdf["_mp"] == "miravia"]),
890
+ "ae": _stats(rdf[rdf["_mp"] == "ae"]),
891
+ }
892
+
893
+
894
+ def _build_fbm_dist(df) -> Dict:
895
+ if df.empty:
896
+ return {"all": [0, 0], "miravia": [0, 0], "ae": [0, 0]}
897
+ s = df.copy()
898
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
899
+ s["is_fbl"] = _safe_num(s["is_fbl"])
900
+
901
+ def _dist(sub):
902
+ if sub.empty:
903
+ return [0, 0]
904
+ fbm = int((sub["is_fbl"] == 1).sum())
905
+ return [fbm, len(sub) - fbm]
906
+
907
+ return {
908
+ "all": _dist(s),
909
+ "miravia": _dist(s[s["_mp"] == "miravia"]),
910
+ "ae": _dist(s[s["_mp"] == "ae"]),
911
+ }
912
+
913
+
914
+ def _build_digital_dist(df) -> Dict:
915
+ if df.empty:
916
+ return {"all": [100, 0], "miravia": [100, 0], "ae": [100, 0]}
917
+ s = df.copy()
918
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
919
+ s["is_digital"] = _safe_num(s["is_digital"])
920
+
921
+ def _dist(sub):
922
+ if sub.empty:
923
+ return [100, 0]
924
+ digital = int((sub["is_digital"] == 1).sum())
925
+ physical = len(sub) - digital
926
+ total = len(sub) or 1
927
+ return [round(physical / total * 100, 1), round(digital / total * 100, 1)]
928
+
929
+ return {
930
+ "all": _dist(s),
931
+ "miravia": _dist(s[s["_mp"] == "miravia"]),
932
+ "ae": _dist(s[s["_mp"] == "ae"]),
933
+ }
934
+
935
+
936
+ def _build_aov_buckets(df) -> Dict:
937
+ """AOV distribution with deduplication."""
938
+ import pandas as pd
939
+ if df.empty:
940
+ return {"all": [], "miravia": [], "ae": []}
941
+ # Deduplicate to avoid inflated buyer_paid per order
942
+ s = _dedup_items(df).copy()
943
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
944
+ s["_paid"] = _safe_num(s["buyer_paid"])
945
+ bins = [0, 10, 20, 30, 50, 1e9]
946
+ labels = ["<10", "10-20", "20-30", "30-50", ">=50"]
947
+
948
+ def _buckets(sub):
949
+ if sub.empty:
950
+ return []
951
+ per_order = sub.groupby("order_id")["_paid"].sum()
952
+ if per_order.empty:
953
+ return []
954
+ cuts = pd.cut(per_order, bins=bins, labels=labels, include_lowest=True)
955
+ c = cuts.value_counts().reindex(labels, fill_value=0)
956
+ total = int(c.sum()) or 1
957
+ return [{"range": lbl, "count": int(c[lbl]), "pct": round(c[lbl] / total * 100, 1)}
958
+ for lbl in labels]
959
+
960
+ return {
961
+ "all": _buckets(s),
962
+ "miravia": _buckets(s[s["_mp"] == "miravia"]),
963
+ "ae": _buckets(s[s["_mp"] == "ae"]),
964
+ }
965
+
966
+
967
+ def _build_voucher_stats(df) -> Dict:
968
+ if df.empty:
969
+ return {"usage_rate": {"all": 0, "miravia": 0, "ae": 0},
970
+ "avg_amount": {"all": 0, "miravia": 0, "ae": 0}}
971
+ s = df.copy()
972
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
973
+ s["_disc"] = _safe_num(s["discount_seller"])
974
+
975
+ def _stats(sub):
976
+ if sub.empty:
977
+ return 0, 0
978
+ has_disc = int((sub["_disc"] > 0).sum())
979
+ rate = has_disc / len(sub) * 100 if len(sub) > 0 else 0
980
+ avg = float(sub.loc[sub["_disc"] > 0, "_disc"].mean()) if has_disc > 0 else 0
981
+ return round(rate, 1), round(avg, 2)
982
+
983
+ ra, aa = _stats(s)
984
+ rm, am = _stats(s[s["_mp"] == "miravia"])
985
+ re_, ae_ = _stats(s[s["_mp"] == "ae"])
986
+ return {
987
+ "usage_rate": {"all": ra, "miravia": rm, "ae": re_},
988
+ "avg_amount": {"all": aa, "miravia": am, "ae": ae_},
989
+ }
990
+
991
+
992
+ def _build_shipping_stats(df) -> Dict:
993
+ if df.empty:
994
+ return {"all": {}, "miravia": {}, "ae": {}}
995
+ s = df.copy()
996
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
997
+ s["_ship"] = _safe_num(s["shipping_paid"])
998
+ s["_disc"] = _safe_num(s["shipping_fee_disc_seller"])
999
+
1000
+ def _stats(sub):
1001
+ if sub.empty:
1002
+ return {}
1003
+ total = float(sub["_ship"].sum())
1004
+ avg = float(sub["_ship"].mean())
1005
+ disc = float(sub["_disc"].sum())
1006
+ disc_pct = disc / (total + disc) * 100 if (total + disc) > 0 else 0
1007
+ free = int((sub["_ship"] == 0).sum())
1008
+ free_pct = free / len(sub) * 100 if len(sub) > 0 else 0
1009
+ return {
1010
+ "total": round(total, 2),
1011
+ "avg": round(avg, 2),
1012
+ "disc": round(disc, 2),
1013
+ "disc_pct": round(disc_pct, 1),
1014
+ "free_pct": round(free_pct, 1),
1015
+ }
1016
+
1017
+ return {
1018
+ "all": _stats(s),
1019
+ "miravia": _stats(s[s["_mp"] == "miravia"]),
1020
+ "ae": _stats(s[s["_mp"] == "ae"]),
1021
+ }
1022
+
1023
+
1024
+ def _build_discount_depth(df) -> Dict:
1025
+ import pandas as pd
1026
+ if df.empty:
1027
+ return {"all": [], "miravia": [], "ae": []}
1028
+ s = df.copy()
1029
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
1030
+ s["_paid"] = _safe_num(s["buyer_paid"])
1031
+ s["_price"] = _safe_num(s["unit_price"])
1032
+ bins = [-1, 0, 5, 10, 20, 1e9]
1033
+ labels = ["0%", "1-5%", "6-10%", "11-20%", ">20%"]
1034
+
1035
+ def _depth(sub):
1036
+ if sub.empty:
1037
+ return []
1038
+ sub = sub.copy()
1039
+ sub["_pct"] = ((sub["_price"] - sub["_paid"]) / sub["_price"] * 100).where(sub["_price"] > 0, 0)
1040
+ cuts = pd.cut(sub["_pct"], bins=bins, labels=labels, include_lowest=True)
1041
+ c = cuts.value_counts().reindex(labels, fill_value=0)
1042
+ total = int(c.sum()) or 1
1043
+ return [{"range": lbl, "count": int(c[lbl]), "pct": round(c[lbl] / total * 100, 1)}
1044
+ for lbl in labels]
1045
+
1046
+ return {
1047
+ "all": _depth(s),
1048
+ "miravia": _depth(s[s["_mp"] == "miravia"]),
1049
+ "ae": _depth(s[s["_mp"] == "ae"]),
1050
+ }
1051
+
1052
+
1053
+ def _build_cat_dist(df, t=None) -> Dict:
1054
+ """Category distribution with deduplication for accurate GMV and refund rates."""
1055
+ if df.empty:
1056
+ return {"all": [], "miravia": [], "ae": []}
1057
+ # Deduplicate for item-level GMV
1058
+ s = _dedup_items(df).copy()
1059
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
1060
+ s["_paid"] = _safe_num(s["buyer_paid"])
1061
+ s["_cat"] = s["product_name"].apply(lambda x: _classify_category(x, t=t))
1062
+ # Mark items with active refund
1063
+ active_refund_items = set()
1064
+ df_refunds = _active_refund_rows(df)
1065
+ if not df_refunds.empty:
1066
+ active_refund_items = set(df_refunds["order_item_id"].dropna().unique())
1067
+ s["_has_refund"] = s["order_item_id"].isin(active_refund_items).astype(int)
1068
+
1069
+ def _dist(sub):
1070
+ if sub.empty:
1071
+ return []
1072
+ g = sub.groupby("_cat").agg(
1073
+ gmv=("_paid", "sum"),
1074
+ count=("order_item_id", "count"),
1075
+ refunds=("_has_refund", "sum"),
1076
+ ).reset_index()
1077
+ g["rr"] = (g["refunds"] / g["count"]).fillna(0)
1078
+ g = g.sort_values("gmv", ascending=False)
1079
+ return [{"name": r["_cat"], "gmv": round(float(r["gmv"]), 2),
1080
+ "rr": round(float(r["rr"]), 4)} for _, r in g.iterrows()]
1081
+
1082
+ return {
1083
+ "all": _dist(s),
1084
+ "miravia": _dist(s[s["_mp"] == "miravia"]),
1085
+ "ae": _dist(s[s["_mp"] == "ae"]),
1086
+ }
1087
+
1088
+
1089
+ def _build_buyer_stats(df) -> Dict:
1090
+ if df.empty:
1091
+ return {"all": {"buyer_count": 0, "repurchase_rate": 0, "top5_share": 0},
1092
+ "miravia": {"buyer_count": 0, "repurchase_rate": 0, "top5_share": 0},
1093
+ "ae": {"buyer_count": 0, "repurchase_rate": 0, "top5_share": 0}}
1094
+ s = df.copy()
1095
+ s["_mp"] = s["marketplace"].apply(_normalize_mp)
1096
+ s["_buyer"] = s["buyer_id"].fillna("").astype(str)
1097
+ s.loc[s["_buyer"] == "", "_buyer"] = s["buyer_name"].fillna("").astype(str)
1098
+ s["_paid"] = _safe_num(s["buyer_paid"])
1099
+
1100
+ def _stats(sub):
1101
+ named = sub[sub["_buyer"] != ""]
1102
+ if named.empty:
1103
+ return {"buyer_count": 0, "repurchase_rate": 0, "top5_share": 0}
1104
+ per_buyer = named.groupby("_buyer")["order_id"].nunique()
1105
+ bcount = int(len(per_buyer))
1106
+ if bcount == 0:
1107
+ return {"buyer_count": 0, "repurchase_rate": 0, "top5_share": 0}
1108
+ returning = int((per_buyer > 1).sum())
1109
+ repurchase = round(returning / bcount * 100, 1)
1110
+ per_buyer_gmv = named.groupby("_buyer")["_paid"].sum().sort_values(ascending=False)
1111
+ total_gmv = float(per_buyer_gmv.sum()) or 1.0
1112
+ top5 = round(float(per_buyer_gmv.head(5).sum()) / total_gmv * 100, 1)
1113
+ return {"buyer_count": bcount, "repurchase_rate": repurchase, "top5_share": top5}
1114
+
1115
+ return {
1116
+ "all": _stats(s),
1117
+ "miravia": _stats(s[s["_mp"] == "miravia"]),
1118
+ "ae": _stats(s[s["_mp"] == "ae"]),
1119
+ }
1120
+
1121
+
1122
+ # -------- overall metrics --------
1123
+
1124
+ def _compute_overall(df) -> Dict:
1125
+ """Compute overall KPI metrics with correct deduplication.
1126
+
1127
+ After one-to-many refund expansion, the same item may appear in multiple rows.
1128
+ - Item-level metrics (GMV, items, cancel): use deduplicated df (one row per item)
1129
+ - Refund metrics: use active refunds only, deduplicated by refund_id
1130
+ - Refund rate: fraction of unique items that have at least one active refund
1131
+ """
1132
+ if df.empty:
1133
+ return {"orders": 0, "items": 0, "gmv": 0, "aov": 0,
1134
+ "cancel_count": 0, "cancel_rate": 0,
1135
+ "refund_count": 0, "refund_rate": 0,
1136
+ "refund_amount": 0, "net_revenue": 0}
1137
+ # Item-level: deduplicate to avoid inflation from refund expansion
1138
+ df_items = _dedup_items(df)
1139
+ paid = _safe_num(df_items["buyer_paid"])
1140
+ gmv = float(paid.sum())
1141
+ orders_n = int(df_items["order_id"].nunique())
1142
+ items_n = len(df_items)
1143
+ aov = gmv / orders_n if orders_n else 0
1144
+ cancel_count = int(df_items["is_canceled"].fillna(False).astype(bool).sum())
1145
+ cancel_rate = cancel_count / items_n if items_n else 0
1146
+ # Refund: active refunds only, deduplicated by refund_id
1147
+ df_refunds = _active_refund_rows(df)
1148
+ refund_count = len(df_refunds)
1149
+ # Refund rate: unique items with active refund / total unique items
1150
+ items_with_refund = int(df_refunds["order_item_id"].nunique()) if not df_refunds.empty else 0
1151
+ refund_rate = items_with_refund / items_n if items_n else 0
1152
+ refund_amount = float(_safe_num(df_refunds["refund_amount"]).sum()) if not df_refunds.empty else 0
1153
+ net_revenue = gmv - refund_amount
1154
+ return {
1155
+ "orders": orders_n, "items": items_n,
1156
+ "gmv": round(gmv, 2), "aov": round(aov, 2),
1157
+ "cancel_count": cancel_count, "cancel_rate": round(cancel_rate, 4),
1158
+ "refund_count": refund_count, "refund_rate": round(refund_rate, 4),
1159
+ "refund_amount": round(refund_amount, 2),
1160
+ "net_revenue": round(net_revenue, 2),
1161
+ }
1162
+
1163
+
1164
+ # -------- metrics export (for Agent analysis) --------
1165
+
1166
+ def _build_metrics_for_preset(df, currency: str) -> Dict:
1167
+ """Build a compact metrics dict for a single preset window.
1168
+
1169
+ Used by export_metrics() to give Agent LLM structured data
1170
+ for generating dynamic executive summaries.
1171
+ """
1172
+ overall = _compute_overall(df)
1173
+
1174
+ # Top refund reasons (compact)
1175
+ top_refund_reasons = []
1176
+ rmask = df["refund_id"].notna() & (df["refund_id"] != "") if not df.empty else []
1177
+ if not df.empty and any(rmask):
1178
+ rdf = df[rmask].copy()
1179
+ rdf["_amt"] = _safe_num(rdf["refund_amount"])
1180
+ total_refunds = len(rdf)
1181
+ for reason, g in rdf.groupby("refund_reason", dropna=False):
1182
+ label = reason if reason else "Not specified"
1183
+ top_refund_reasons.append({
1184
+ "reason": str(label),
1185
+ "count": len(g),
1186
+ "pct": round(len(g) / total_refunds, 4) if total_refunds else 0,
1187
+ "amount": round(float(g["_amt"].sum()), 2),
1188
+ })
1189
+ top_refund_reasons.sort(key=lambda x: -x["count"])
1190
+ top_refund_reasons = top_refund_reasons[:5]
1191
+
1192
+ # Top SKUs (compact)
1193
+ top_skus = []
1194
+ if not df.empty:
1195
+ s = df.copy()
1196
+ s["quantity"] = _safe_num(s["quantity"])
1197
+ rmask2 = s["refund_id"].notna() & (s["refund_id"] != "")
1198
+ for (sku, name), g in s.groupby(["sku", "product_name"], dropna=False):
1199
+ qty = int(g["quantity"].sum())
1200
+ refund_count = int(rmask2[g.index].sum())
1201
+ rr = refund_count / len(g) if len(g) else 0
1202
+ top_skus.append({
1203
+ "sku": str(sku) if sku else "-",
1204
+ "name": str(name) if name else "-",
1205
+ "qty": qty,
1206
+ "refund_rate": round(rr, 4),
1207
+ })
1208
+ top_skus.sort(key=lambda x: -x["qty"])
1209
+ top_skus = top_skus[:10]
1210
+
1211
+ # Top carriers (compact)
1212
+ top_carriers = []
1213
+ if not df.empty:
1214
+ s = df.copy()
1215
+ s["shipping_provider"] = s["shipping_provider"].fillna("Unknown")
1216
+ s["_is_cancel"] = s["is_canceled"].fillna(False).astype(bool)
1217
+ rmask3 = s["refund_id"].notna() & (s["refund_id"] != "")
1218
+ for carrier, g in s.groupby("shipping_provider"):
1219
+ total = len(g)
1220
+ cr = float(g["_is_cancel"].sum()) / total if total else 0
1221
+ rr = float(rmask3[g.index].sum()) / total if total else 0
1222
+ top_carriers.append({
1223
+ "name": carrier,
1224
+ "orders": total,
1225
+ "cancel_rate": round(cr, 4),
1226
+ "refund_rate": round(rr, 4),
1227
+ })
1228
+ top_carriers.sort(key=lambda x: -x["orders"])
1229
+ top_carriers = top_carriers[:5]
1230
+
1231
+ # Platform split
1232
+ platform_split = {"miravia": 0, "ae": 0, "other": 0}
1233
+ if not df.empty:
1234
+ mp = df["marketplace"].apply(_normalize_mp)
1235
+ for p, cnt in mp.value_counts().items():
1236
+ if p in platform_split:
1237
+ platform_split[p] = int(cnt)
1238
+ else:
1239
+ platform_split["other"] += int(cnt)
1240
+
1241
+ return {
1242
+ "overall": overall,
1243
+ "topRefundReasons": top_refund_reasons,
1244
+ "topSkus": top_skus,
1245
+ "topCarriers": top_carriers,
1246
+ "platformSplit": platform_split,
1247
+ }
1248
+
1249
+
1250
+ def export_metrics(rows: List[Dict[str, Any]], export_columns,
1251
+ range_start: str = "", range_end: str = "") -> Dict:
1252
+ """Export structured metrics for Agent LLM analysis.
1253
+
1254
+ Returns a dict with metrics for each preset time window,
1255
+ suitable for JSON serialization. The Agent reads this to
1256
+ generate dynamic executive summaries.
1257
+ """
1258
+ import pandas as pd
1259
+
1260
+ df = (pd.DataFrame(rows, columns=export_columns)
1261
+ if rows else pd.DataFrame(columns=export_columns))
1262
+ currency = _common_currency(df)
1263
+
1264
+ # Full range
1265
+ presets: Dict[str, Any] = {
1266
+ "0": {
1267
+ **_build_metrics_for_preset(df, currency),
1268
+ "dateRange": {"start": range_start, "end": range_end},
1269
+ }
1270
+ }
1271
+
1272
+ # Per-preset windows
1273
+ # IMPORTANT: Use normalize() to align with dailyData calendar-day boundaries.
1274
+ # Without this, the cutoff would use the exact max_date timestamp (e.g.,
1275
+ # 14:43:35), excluding orders on the boundary day that were created before
1276
+ # that time — causing metrics.json to disagree with HTML KPI cards which
1277
+ # aggregate from dailyData using full calendar days.
1278
+ if not df.empty:
1279
+ dates = pd.to_datetime(df["created_at"], errors="coerce")
1280
+ max_date = dates.max()
1281
+ for days in _PRESET_DAYS:
1282
+ cutoff = max_date.normalize() - pd.Timedelta(days=days - 1)
1283
+ subset = df[dates >= cutoff]
1284
+ if len(subset) < len(df):
1285
+ min_d = subset["created_at"].min() if not subset.empty else ""
1286
+ presets[str(days)] = {
1287
+ **_build_metrics_for_preset(subset, currency),
1288
+ "dateRange": {"start": str(min_d), "end": range_end},
1289
+ }
1290
+ else:
1291
+ presets[str(days)] = presets["0"]
1292
+
1293
+ return {
1294
+ "currency": currency,
1295
+ "presets": presets,
1296
+ }
1297
+
1298
+
1299
+ # -------- dimensional data helper --------
1300
+
1301
+ def _compute_dims(df, currency: str, t=None) -> Dict:
1302
+ """Compute all dimensional breakdowns for a given DataFrame subset."""
1303
+ cancel_df = df[df["is_canceled"].fillna(False).astype(bool)] if not df.empty else df
1304
+ overall = _compute_overall(df)
1305
+ return {
1306
+ "skus": _build_skus(df, t=t),
1307
+ "cities": _build_cities(df),
1308
+ "carriers": _build_carriers(df),
1309
+ "refReasons": _build_ref_reasons(df, currency, t=t),
1310
+ "abBuyers": _build_ab_buyers(df),
1311
+ "countries": _build_countries(df),
1312
+ "payCountry": _build_pay_country(df),
1313
+ "payMethods": _build_dist_by_platform(
1314
+ df.drop_duplicates("order_id") if not df.empty else df,
1315
+ "payment_method", "Unknown"),
1316
+ "cancelReasons": _build_dist_by_platform(
1317
+ cancel_df, "cancel_reason", "Not specified"),
1318
+ "cancelInitiator": _build_cancel_initiator(df),
1319
+ "cancelAgree": _build_cancel_agree(df),
1320
+ "refundTypes": _build_refund_types(df),
1321
+ "dutyPartyDist": _build_duty_party(df),
1322
+ "fastRefund": _build_fast_refund(df),
1323
+ "fbmDist": _build_fbm_dist(df),
1324
+ "digitalDist": _build_digital_dist(df),
1325
+ "aovBuckets": _build_aov_buckets(df),
1326
+ "statusDist": _build_dist_by_platform(df, "item_status", "Unknown"),
1327
+ "shipTypeDist": _build_dist_by_platform(df, "shipment_type", "Unknown"),
1328
+ "voucherStats": _build_voucher_stats(df),
1329
+ "shippingStats": _build_shipping_stats(df),
1330
+ "discountDepth": _build_discount_depth(df),
1331
+ "catDist": _build_cat_dist(df, t=t),
1332
+ "buyerStats": _build_buyer_stats(df),
1333
+ "overall": overall,
1334
+ }
1335
+
1336
+
1337
+ # -------- AI insights generation (rule-based) --------
1338
+
1339
+ _INSIGHT_TEMPLATES = {
1340
+ "zh": {
1341
+ "summary_annual": '<p>Past {days} days: <b>{orders:,}</b> orders, GMV <b>{currency}{gmv:,.0f}</b>, net revenue <b>{currency}{net:,.0f}</b>. Cancel rate {cr:.1%}, refund rate {rr:.1%}. {trend}</p>',
1342
+ "summary_short": '<p>Last {days} days: <b>{orders:,}</b> orders, GMV <b>{currency}{gmv:,.0f}</b>, AOV <b>{currency}{aov:,.2f}</b>. Refund rate <b>{rr:.1%}</b>, cancel rate {cr:.1%}. Net revenue {currency}{net:,.0f}. {trend}</p>',
1343
+ "summary_nodata": '<p>Insufficient data in this time window for meaningful insights.</p>',
1344
+ "alert_high_refund": 'Refund rate {rr:.1%} exceeds {threshold:.0%} threshold',
1345
+ "alert_high_cancel": 'Cancel rate {cr:.1%} is elevated',
1346
+ "alert_top_reason": 'Top refund reason "{reason}" accounts for {pct:.1%}, involving {currency}{amt:,.0f} loss',
1347
+ "alert_sku_anomaly": 'SKU "{name}" refund rate {rr:.1%} ({qty} orders), requires investigation',
1348
+ "alert_trend_improve": 'Recent trend improving: refund rate dropped from {long_rr:.1%} to {short_rr:.1%}',
1349
+ "alert_aov_change": 'AOV {direction}: last {days} days {currency}{aov:.2f} vs annual {currency}{base_aov:.2f}',
1350
+ "action_fix_reason": 'Investigate root cause of "{reason}" refunds, reduce non-voluntary refunds',
1351
+ "action_stock": 'Optimize inventory management, integrate real-time alerts to prevent overselling cancellations',
1352
+ "action_sku_check": 'Investigate high-refund SKU "{name}", confirm if test data contamination',
1353
+ "action_expand": 'Business trend is positive, consider scaling ad spend',
1354
+ "action_monitor": 'Continue monitoring refund rate changes, consolidate recent improvements',
1355
+ },
1356
+ "en": {
1357
+ "summary_annual": '<p>Past {days} days: <b>{orders:,}</b> orders, GMV <b>{currency}{gmv:,.0f}</b>, net revenue <b>{currency}{net:,.0f}</b>. Cancel rate {cr:.1%}, refund rate {rr:.1%}. {trend}</p>',
1358
+ "summary_short": '<p>Last {days} days: <b>{orders:,}</b> orders, GMV <b>{currency}{gmv:,.0f}</b>, AOV <b>{currency}{aov:,.2f}</b>. Refund rate <b>{rr:.1%}</b>, cancel rate {cr:.1%}. Net revenue {currency}{net:,.0f}. {trend}</p>',
1359
+ "summary_nodata": '<p>Insufficient data in this time window for meaningful insights.</p>',
1360
+ "alert_high_refund": 'Refund rate {rr:.1%} exceeds {threshold:.0%} threshold',
1361
+ "alert_high_cancel": 'Cancel rate {cr:.1%} is elevated',
1362
+ "alert_top_reason": 'Top refund reason "{reason}" accounts for {pct:.1%}, {currency}{amt:,.0f} lost',
1363
+ "alert_sku_anomaly": 'SKU "{name}" has {rr:.1%} refund rate ({qty} orders), requires investigation',
1364
+ "alert_trend_improve": 'Positive trend: refund rate dropped from {long_rr:.1%} to {short_rr:.1%}',
1365
+ "alert_aov_change": 'AOV {direction}: last {days}d {currency}{aov:.2f} vs annual {currency}{base_aov:.2f}',
1366
+ "action_fix_reason": 'Investigate root cause of "{reason}" refunds to reduce involuntary returns',
1367
+ "action_stock": 'Improve inventory management with real-time alerts to prevent overselling',
1368
+ "action_sku_check": 'Audit high-refund SKU "{name}" — verify if test data is polluting metrics',
1369
+ "action_expand": 'Business trending positively — consider scaling ad spend',
1370
+ "action_monitor": 'Continue monitoring refund rate to consolidate recent improvements',
1371
+ },
1372
+ "es": {
1373
+ "summary_annual": '<p>Últimos {days} días: <b>{orders:,}</b> pedidos, GMV <b>{currency}{gmv:,.0f}</b>, ingresos netos <b>{currency}{net:,.0f}</b>. Tasa cancelación {cr:.1%}, tasa devolución {rr:.1%}. {trend}</p>',
1374
+ "summary_short": '<p>Últimos {days} días: <b>{orders:,}</b> pedidos, GMV <b>{currency}{gmv:,.0f}</b>, AOV <b>{currency}{aov:,.2f}</b>. Tasa devolución <b>{rr:.1%}</b>, tasa cancelación {cr:.1%}. Ingresos netos {currency}{net:,.0f}. {trend}</p>',
1375
+ "summary_nodata": '<p>Datos insuficientes en esta ventana temporal para generar información útil.</p>',
1376
+ "alert_high_refund": 'Tasa devolución {rr:.1%} supera umbral {threshold:.0%}',
1377
+ "alert_high_cancel": 'Tasa cancelación {cr:.1%} elevada',
1378
+ "alert_top_reason": 'Motivo principal "{reason}" supone {pct:.1%}, {currency}{amt:,.0f} perdidos',
1379
+ "alert_sku_anomaly": 'SKU "{name}" tiene {rr:.1%} tasa devolución ({qty} pedidos)',
1380
+ "alert_trend_improve": 'Tendencia positiva: devoluciones bajan de {long_rr:.1%} a {short_rr:.1%}',
1381
+ "alert_aov_change": 'AOV {direction}: últimos {days}d {currency}{aov:.2f} vs anual {currency}{base_aov:.2f}',
1382
+ "action_fix_reason": 'Investigar causa raíz de devoluciones por "{reason}"',
1383
+ "action_stock": 'Mejorar gestión de inventario con alertas en tiempo real',
1384
+ "action_sku_check": 'Auditar SKU "{name}" con alta tasa de devolución',
1385
+ "action_expand": 'Negocio en tendencia positiva — considerar aumentar inversión',
1386
+ "action_monitor": 'Seguir monitorizando tasa de devolución para consolidar mejoras',
1387
+ },
1388
+ "it": {
1389
+ "summary_annual": '<p>Ultimi {days} giorni: <b>{orders:,}</b> ordini, GMV <b>{currency}{gmv:,.0f}</b>, ricavo netto <b>{currency}{net:,.0f}</b>. Tasso cancellazione {cr:.1%}, tasso rimborso {rr:.1%}. {trend}</p>',
1390
+ "summary_short": '<p>Ultimi {days} giorni: <b>{orders:,}</b> ordini, GMV <b>{currency}{gmv:,.0f}</b>, AOV <b>{currency}{aov:,.2f}</b>. Tasso rimborso <b>{rr:.1%}</b>, tasso cancellazione {cr:.1%}. Ricavo netto {currency}{net:,.0f}. {trend}</p>',
1391
+ "summary_nodata": '<p>Dati insufficienti in questa finestra temporale.</p>',
1392
+ "alert_high_refund": 'Tasso rimborso {rr:.1%} supera soglia {threshold:.0%}',
1393
+ "alert_high_cancel": 'Tasso cancellazione {cr:.1%} elevato',
1394
+ "alert_top_reason": 'Motivo principale "{reason}" rappresenta {pct:.1%}, {currency}{amt:,.0f} persi',
1395
+ "alert_sku_anomaly": 'SKU "{name}" ha {rr:.1%} tasso rimborso ({qty} ordini)',
1396
+ "alert_trend_improve": 'Tendenza positiva: rimborsi scesi da {long_rr:.1%} a {short_rr:.1%}',
1397
+ "alert_aov_change": 'AOV {direction}: ultimi {days}g {currency}{aov:.2f} vs annuale {currency}{base_aov:.2f}',
1398
+ "action_fix_reason": 'Indagare causa principale dei rimborsi per "{reason}"',
1399
+ "action_stock": 'Migliorare gestione inventario con avvisi in tempo reale',
1400
+ "action_sku_check": 'Verificare SKU "{name}" con alto tasso rimborso',
1401
+ "action_expand": 'Business in tendenza positiva — valutare aumento investimenti',
1402
+ "action_monitor": 'Continuare a monitorare tasso rimborso per consolidare miglioramenti',
1403
+ },
1404
+ }
1405
+
1406
+
1407
+ def _get_insight_tpl(lang: str) -> Dict[str, str]:
1408
+ return _INSIGHT_TEMPLATES.get(lang, _INSIGHT_TEMPLATES["en"])
1409
+
1410
+
1411
+ def _generate_preset_insights(overall: Dict, top_reasons: List, top_skus: List,
1412
+ base_overall: Dict, days: int, currency: str,
1413
+ lang: str) -> Dict:
1414
+ """Generate insights for a single preset time window.
1415
+
1416
+ Args:
1417
+ overall: metrics for this preset window
1418
+ top_reasons: top refund reasons for this window
1419
+ top_skus: top SKUs for this window
1420
+ base_overall: full-range overall metrics (for trend comparison)
1421
+ days: the preset day count (7, 30, 90, 180, 365, or 0=all)
1422
+ currency: currency symbol (e.g. '€')
1423
+ lang: language code
1424
+ """
1425
+ tpl = _get_insight_tpl(lang)
1426
+ cur_sym = "€" if currency.upper() == "EUR" else currency + " "
1427
+
1428
+ orders = overall.get("orders", 0)
1429
+ items = overall.get("items", 0)
1430
+ gmv = overall.get("gmv", 0)
1431
+ aov = overall.get("aov", 0)
1432
+ cr = overall.get("cancel_rate", 0)
1433
+ rr = overall.get("refund_rate", 0)
1434
+ net = overall.get("net_revenue", 0)
1435
+ refund_amt = overall.get("refund_amount", 0)
1436
+
1437
+ base_rr = base_overall.get("refund_rate", 0)
1438
+ base_cr = base_overall.get("cancel_rate", 0)
1439
+ base_aov = base_overall.get("aov", 0)
1440
+
1441
+ if orders < 5:
1442
+ return {"summary": tpl["summary_nodata"], "highlights": [], "actions": []}
1443
+
1444
+ # ---- Summary ----
1445
+ trend_parts = []
1446
+ if days > 0 and days < 365 and base_rr > 0:
1447
+ if rr < base_rr * 0.7:
1448
+ trend_parts.append("<b>" + ({"zh": "Strong positive trend", "en": "Strong positive trend",
1449
+ "es": "Tendencia muy positiva", "it": "Tendenza molto positiva"}
1450
+ .get(lang, "Strong positive trend")) + "</b>")
1451
+ elif rr < base_rr * 0.9:
1452
+ trend_parts.append({"zh": "Improving trend", "en": "Improving trend",
1453
+ "es": "Tendencia mejorando", "it": "Tendenza in miglioramento"}
1454
+ .get(lang, "Improving trend"))
1455
+
1456
+ trend_str = ". ".join(trend_parts)
1457
+
1458
+ if days >= 180:
1459
+ summary = tpl["summary_annual"].format(
1460
+ days=days, orders=orders, currency=cur_sym, gmv=gmv,
1461
+ net=net, cr=cr, rr=rr, trend=trend_str)
1462
+ else:
1463
+ summary = tpl["summary_short"].format(
1464
+ days=days, orders=orders, currency=cur_sym, gmv=gmv,
1465
+ aov=aov, rr=rr, cr=cr, net=net, trend=trend_str)
1466
+
1467
+ # ---- Highlights ----
1468
+ highlights = []
1469
+
1470
+ # High refund rate
1471
+ if rr > 0.20:
1472
+ highlights.append({"severity": "high",
1473
+ "title": tpl["alert_high_refund"].format(rr=rr, threshold=0.20),
1474
+ "detail": f"{cur_sym}{refund_amt:,.0f}"})
1475
+ elif rr > 0.10:
1476
+ highlights.append({"severity": "medium",
1477
+ "title": tpl["alert_high_refund"].format(rr=rr, threshold=0.10),
1478
+ "detail": f"{cur_sym}{refund_amt:,.0f}"})
1479
+
1480
+ # High cancel rate
1481
+ if cr > 0.30:
1482
+ highlights.append({"severity": "high" if cr > 0.40 else "medium",
1483
+ "title": tpl["alert_high_cancel"].format(cr=cr),
1484
+ "detail": ""})
1485
+
1486
+ # Top refund reason dominance
1487
+ if top_reasons:
1488
+ top1 = top_reasons[0]
1489
+ if top1.get("pct", 0) > 0.30:
1490
+ highlights.append({"severity": "high" if top1["pct"] > 0.50 else "medium",
1491
+ "title": tpl["alert_top_reason"].format(
1492
+ reason=top1["reason"], pct=top1["pct"],
1493
+ currency=cur_sym, amt=top1.get("amount", 0)),
1494
+ "detail": ""})
1495
+
1496
+ # Anomalous SKU refund rate
1497
+ for sku in (top_skus or [])[:5]:
1498
+ if sku.get("refund_rate", 0) > 0.50 and sku.get("qty", 0) > 20:
1499
+ name = sku.get("name", sku.get("sku", "-"))
1500
+ if len(name) > 30:
1501
+ name = name[:27] + "..."
1502
+ highlights.append({"severity": "high",
1503
+ "title": tpl["alert_sku_anomaly"].format(
1504
+ name=name, rr=sku["refund_rate"], qty=sku["qty"]),
1505
+ "detail": ""})
1506
+ break # only report top-1 anomalous SKU
1507
+
1508
+ # Trend improvement (compare current window vs full range)
1509
+ if days > 0 and days <= 90 and base_rr > 0.05 and rr < base_rr * 0.6:
1510
+ highlights.append({"severity": "low",
1511
+ "title": tpl["alert_trend_improve"].format(
1512
+ long_rr=base_rr, short_rr=rr),
1513
+ "detail": ""})
1514
+
1515
+ # AOV significant change
1516
+ if base_aov > 0 and days > 0 and days <= 90:
1517
+ aov_ratio = aov / base_aov if base_aov else 1
1518
+ if aov_ratio > 2.0 or aov_ratio < 0.5:
1519
+ direction = {"zh": "significantly up" if aov_ratio > 1 else "significantly down",
1520
+ "en": "significantly up" if aov_ratio > 1 else "significantly down",
1521
+ "es": "sube significativamente" if aov_ratio > 1 else "baja significativamente",
1522
+ "it": "significativamente in aumento" if aov_ratio > 1 else "significativamente in calo",
1523
+ }.get(lang, "significant change")
1524
+ highlights.append({"severity": "low",
1525
+ "title": tpl["alert_aov_change"].format(
1526
+ direction=direction, days=days,
1527
+ currency=cur_sym, aov=aov, base_aov=base_aov),
1528
+ "detail": ""})
1529
+
1530
+ # Cap at 5 highlights
1531
+ highlights = highlights[:5]
1532
+
1533
+ # ---- Actions ----
1534
+ actions = []
1535
+
1536
+ # P0: Fix top refund reason if dominant
1537
+ if top_reasons and top_reasons[0].get("pct", 0) > 0.30:
1538
+ actions.append({"priority": "P0",
1539
+ "text": tpl["action_fix_reason"].format(reason=top_reasons[0]["reason"])})
1540
+
1541
+ # P0: SKU anomaly
1542
+ for sku in (top_skus or [])[:5]:
1543
+ if sku.get("refund_rate", 0) > 0.50 and sku.get("qty", 0) > 20:
1544
+ name = sku.get("name", sku.get("sku", "-"))
1545
+ if len(name) > 30:
1546
+ name = name[:27] + "..."
1547
+ actions.append({"priority": "P0",
1548
+ "text": tpl["action_sku_check"].format(name=name)})
1549
+ break
1550
+
1551
+ # P1: Stock management if "stock" reason is top-3
1552
+ stock_keywords = ["stock", "out of stock", "no hay stock"]
1553
+ for reason_obj in (top_reasons or [])[:3]:
1554
+ reason_str = reason_obj.get("reason", "").lower()
1555
+ if any(kw in reason_str for kw in stock_keywords):
1556
+ actions.append({"priority": "P1", "text": tpl["action_stock"]})
1557
+ break
1558
+
1559
+ # P2: Positive trend → expand
1560
+ if rr < 0.10 and cr < 0.10:
1561
+ actions.append({"priority": "P2", "text": tpl["action_expand"]})
1562
+ elif days > 0 and days <= 90 and base_rr > 0.05 and rr < base_rr * 0.6:
1563
+ actions.append({"priority": "P2", "text": tpl["action_monitor"]})
1564
+
1565
+ # Ensure at least one action
1566
+ if not actions:
1567
+ actions.append({"priority": "P2", "text": tpl["action_monitor"]})
1568
+
1569
+ return {"summary": summary, "highlights": highlights, "actions": actions}
1570
+
1571
+
1572
+ def generate_insights(metrics: Dict, lang: str = "en") -> Dict:
1573
+ """[DEPRECATED] Rule-engine placeholder — no longer called by render().
1574
+
1575
+ Kept for reference only. LLM-generated insights via inject_insights.py
1576
+ are the sole source of executive summary content.
1577
+ """
1578
+ currency = metrics.get("currency", "EUR")
1579
+ presets_data = metrics.get("presets", {})
1580
+
1581
+ base_preset = presets_data.get("0", {})
1582
+ base_overall = base_preset.get("overall", {})
1583
+
1584
+ result_presets = {}
1585
+ for key, preset in presets_data.items():
1586
+ overall = preset.get("overall", {})
1587
+ top_reasons = preset.get("topRefundReasons", [])
1588
+ top_skus = preset.get("topSkus", [])
1589
+ days = int(key) if key != "0" else 0
1590
+
1591
+ result_presets[key] = _generate_preset_insights(
1592
+ overall=overall,
1593
+ top_reasons=top_reasons,
1594
+ top_skus=top_skus,
1595
+ base_overall=base_overall,
1596
+ days=days,
1597
+ currency=currency,
1598
+ lang=lang,
1599
+ )
1600
+
1601
+ return {"presets": result_presets}
1602
+
1603
+
1604
+ # -------- main render --------
1605
+
1606
+ _PRESET_DAYS = [7, 30, 90, 180, 365]
1607
+
1608
+
1609
+ def render(rows: List[Dict[str, Any]], out_path: Path,
1610
+ range_label: str, jinja_env, export_columns,
1611
+ default_max_orders: int, lang: str = "en") -> Path:
1612
+ """Render v4 unified dashboard HTML with embedded AI-generated insights."""
1613
+ import pandas as pd
1614
+
1615
+ t = get_translations(lang)
1616
+
1617
+ df = pd.DataFrame(rows, columns=export_columns) if rows else pd.DataFrame(columns=export_columns)
1618
+ currency = _common_currency(df)
1619
+ overall = _compute_overall(df)
1620
+
1621
+ # ---- compute per-preset dimensional data ----
1622
+ all_dims = _compute_dims(df, currency, t=t)
1623
+ preset_data: Dict[str, Any] = {"0": all_dims} # key "0" = full range
1624
+
1625
+ # IMPORTANT: Use normalize() to align with dailyData calendar-day boundaries.
1626
+ # Without this, the cutoff would use the exact max_date timestamp (e.g.,
1627
+ # 14:43:35), excluding orders on the boundary day that were created before
1628
+ # that time — causing presetData to disagree with HTML KPI cards which
1629
+ # aggregate from dailyData using full calendar days.
1630
+ if not df.empty:
1631
+ dates = pd.to_datetime(df["created_at"], errors="coerce")
1632
+ max_date = dates.max()
1633
+ for days in _PRESET_DAYS:
1634
+ cutoff = max_date.normalize() - pd.Timedelta(days=days - 1)
1635
+ subset = df[dates >= cutoff]
1636
+ if len(subset) < len(df): # only compute if subset differs from all
1637
+ preset_data[str(days)] = _compute_dims(subset, currency, t=t)
1638
+ else:
1639
+ preset_data[str(days)] = all_dims # same as all, share reference
1640
+
1641
+ # ---- agentInsights placeholder (LLM injects real content via inject_insights.py) ----
1642
+ # The rule-engine generate_insights() has been retired — only LLM-generated
1643
+ # multi-language insights are used. The empty placeholder ensures the HTML
1644
+ # renders gracefully until inject_insights.py overwrites this field.
1645
+ agent_insights = {"presets": {}}
1646
+
1647
+ # ---- assemble data payload ----
1648
+ range_start = range_label.split(" ~ ")[0] if " ~ " in range_label else range_label
1649
+ range_end = range_label.split(" ~ ")[1] if " ~ " in range_label else ""
1650
+ data = {
1651
+ "meta": {
1652
+ "range_start": range_start,
1653
+ "range_end": range_end,
1654
+ "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
1655
+ "currency": currency,
1656
+ "total_orders": overall["orders"],
1657
+ "total_items": overall["items"],
1658
+ "buyer_id_available": _check_buyer_id_available(df),
1659
+ },
1660
+ "dailyData": _build_daily_data(df, range_start=range_start, range_end=range_end),
1661
+ "presetData": preset_data,
1662
+ "agentInsights": agent_insights,
1663
+ # Top-level dimensional fields (backward compat, points to "all")
1664
+ **all_dims,
1665
+ }
1666
+
1667
+ # ---- load static assets for inline embedding ----
1668
+ _static_dir = Path(__file__).resolve().parent.parent / "static"
1669
+ _chartjs_path = _static_dir / "chart.umd.min.js"
1670
+ _datalabels_path = _static_dir / "chartjs-plugin-datalabels.min.js"
1671
+ _logo_path = _static_dir / "miravia-logo.svg"
1672
+
1673
+ chartjs_inline = _chartjs_path.read_text(encoding="utf-8") if _chartjs_path.exists() else ""
1674
+ chartjs_datalabels_inline = _datalabels_path.read_text(encoding="utf-8") if _datalabels_path.exists() else ""
1675
+ logo_base64 = base64.b64encode(_logo_path.read_bytes()).decode("ascii") if _logo_path.exists() else ""
1676
+
1677
+ tpl = jinja_env.get_template("report_v4.html")
1678
+ html = tpl.render(
1679
+ data_json=json.dumps(data, ensure_ascii=False, default=str),
1680
+ generated_at=data["meta"]["generated_at"],
1681
+ range_label=range_label,
1682
+ currency=currency,
1683
+ t=t,
1684
+ t_json=json.dumps(t, ensure_ascii=False),
1685
+ all_t_json=json.dumps(TRANSLATIONS, ensure_ascii=False),
1686
+ current_lang=lang,
1687
+ supported_langs=SUPPORTED_LANGS,
1688
+ chartjs_inline=chartjs_inline,
1689
+ chartjs_datalabels_inline=chartjs_datalabels_inline,
1690
+ logo_base64=logo_base64,
1691
+ )
1692
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1693
+ out_path.write_text(html, encoding="utf-8")
1694
+ return out_path