@miraiva_test/miravia-order-report 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +61 -0
- package/dist/.keypair.json +5 -0
- package/dist/README.md +249 -0
- package/dist/SKILL.md +329 -0
- package/dist/VERSION +1 -0
- package/dist/executive_summary_prompt_v2.md +574 -0
- package/dist/reference.md +409 -0
- package/dist/requirements.txt +6 -0
- package/dist/scripts/auth_cli.py +1442 -0
- package/dist/scripts/config.py +216 -0
- package/dist/scripts/cred_crypto.py +235 -0
- package/dist/scripts/estimate_time.py +244 -0
- package/dist/scripts/export_orders.py +1549 -0
- package/dist/scripts/i18n.py +1162 -0
- package/dist/scripts/inject_insights.py +96 -0
- package/dist/scripts/login.bat +14 -0
- package/dist/scripts/miravia_client.py +468 -0
- package/dist/scripts/report_v4.py +1694 -0
- package/dist/scripts/run.bat +15 -0
- package/dist/scripts/skill_crypto.py +86 -0
- package/dist/scripts/skill_keypair.py +86 -0
- package/dist/scripts/skill_logger.py +262 -0
- package/dist/scripts/test_v4_render.py +322 -0
- package/dist/scripts/trace_logger.py +97 -0
- package/dist/scripts/validate_render.py +101 -0
- package/dist/static/chart.umd.min.js +20 -0
- package/dist/static/chartjs-plugin-datalabels.min.js +7 -0
- package/dist/static/miravia-logo.svg +61 -0
- package/dist/templates/report_v4.html +1005 -0
- package/lib/build-dist.js +86 -0
- package/lib/clean-dist.js +18 -0
- package/lib/installer.js +444 -0
- package/package.json +34 -0
|
@@ -0,0 +1,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
|