596 lines
34 KiB
JavaScript
596 lines
34 KiB
JavaScript
import { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlusIcon } from "../components/MetricIcons.jsx";
|
||
import { countryFlag, resolveCountryMeta, stripCountryFlagPrefix } from "./countryMeta.js";
|
||
import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js";
|
||
import { numberValue, ratio } from "../utils/number.js";
|
||
import { formatDateTime, sameRange, todayRange } from "../utils/time.js";
|
||
|
||
export function createDashboardModel(overview, { appCode, countryId, range, timeZone }) {
|
||
const source = overview || {};
|
||
const comparisonCaption = isTodayRange(range, timeZone) ? "与昨日相比" : "近 7 日";
|
||
const recharge = effectiveRechargeUSDMinor(source);
|
||
const userRecharge = effectiveUserRechargeUSDMinor(source);
|
||
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
|
||
const newPaidUsers = readNumber(source, "new_paid_users", "newPaidUsers");
|
||
const activeUsers = readNumber(source, "active_users", "activeUsers");
|
||
const paidUsers = readNumber(source, "paid_users", "paidUsers");
|
||
const rechargeUsers = readNumber(source, "recharge_users", "rechargeUsers", "paid_users", "paidUsers");
|
||
const newUsers = readNumber(source, "new_users", "newUsers", "registrations", "registered_users", "registeredUsers");
|
||
const arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor");
|
||
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
|
||
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
|
||
const coinSellerTransferCoin = readNumber(source, "coin_seller_transfer_coin", "coinSellerTransferCoin");
|
||
const realRoomRobotNormalGiftCoin = readNumber(source, "real_room_robot_normal_gift_coin", "realRoomRobotNormalGiftCoin");
|
||
const realRoomRobotLuckyGiftCoin = readNumber(source, "real_room_robot_lucky_gift_coin", "realRoomRobotLuckyGiftCoin");
|
||
const realRoomRobotSuperGiftCoin = readNumber(source, "real_room_robot_super_gift_coin", "realRoomRobotSuperGiftCoin");
|
||
const realRoomRobotTotalGiftCoin = readNumber(source, "real_room_robot_total_gift_coin", "realRoomRobotTotalGiftCoin") || realRoomRobotNormalGiftCoin + realRoomRobotLuckyGiftCoin + realRoomRobotSuperGiftCoin;
|
||
const realRoomRobotGiftRoomCount = readNumber(source, "real_room_robot_gift_room_count", "realRoomRobotGiftRoomCount");
|
||
const realRoomRobotAvgRoomGiftCoin = readNumber(source, "real_room_robot_avg_room_gift_coin", "realRoomRobotAvgRoomGiftCoin") || Math.round(ratio(realRoomRobotTotalGiftCoin, realRoomRobotGiftRoomCount));
|
||
const luckyGiftPools = normalizeLuckyGiftPools(source);
|
||
const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
|
||
const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
|
||
const luckyGiftPayout = luckyGiftPools.length ? luckyGiftPoolTotals.payout : readNumber(source, "lucky_gift_payout", "luckyGiftPayout");
|
||
const luckyGiftProfit = luckyGiftPools.length ? luckyGiftPoolTotals.profit : readNumber(source, "lucky_gift_profit", "luckyGiftProfit");
|
||
const gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
|
||
const gameProfit = readNumber(source, "game_profit", "gameProfit");
|
||
const countryBreakdown = normalizeCountries(source, countryId);
|
||
const dailyCountryBreakdown = normalizeDailyCountryBreakdown(source);
|
||
const dailySeries = normalizeDailySeries(source);
|
||
const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source);
|
||
const payoutDistribution = normalizeDistribution(source);
|
||
const gameRanking = normalizeGameRanking(source);
|
||
const reportCountryRows = buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, dailySeries, range, source });
|
||
|
||
return {
|
||
appCode,
|
||
businessKpis: [
|
||
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin") }),
|
||
coinMetric("币商出货金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商出货金币", "coin") }),
|
||
coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_turnover", "幸运礼物流水", "coin") }),
|
||
coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_payout", "幸运礼物返奖", "coin") }),
|
||
coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_profit", "幸运礼物利润", "coin") }),
|
||
coinMetric("游戏流水", gameTurnover, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_turnover", "游戏流水", "coin") }),
|
||
coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_profit", "游戏利润", "coin") })
|
||
],
|
||
countryBreakdown,
|
||
dailyCountryBreakdown,
|
||
funnel: [
|
||
{ name: "活跃用户", rate: 1, value: activeUsers },
|
||
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
|
||
{ name: "充值用户", rate: ratio(rechargeUsers, activeUsers), value: rechargeUsers }
|
||
],
|
||
gameMetrics: [
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(source.game_turnover) },
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_payout_delta_rate", "gamePayoutDeltaRate")), label: "派奖", value: formatWholeMoney(source.game_payout) },
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_refund_delta_rate", "gameRefundDeltaRate")), label: "退款", value: formatWholeMoney(source.game_refund) },
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate")), label: "利润", value: formatWholeMoney(source.game_profit) }
|
||
],
|
||
gameRanking,
|
||
giftRanking: normalizeGiftRanking(source),
|
||
kpis: [
|
||
{ caption: comparisonCaption, delta: readDelta(source, "recharge_delta_rate", "rechargeDeltaRate", "total_recharge_delta_rate", "totalRechargeDeltaRate"), icon: CoinIcon, label: "总充值", trend: metricTrend(dailySeries, "recharge_usd_minor", "总充值", "money"), trendFormat: "money", value: formatMoneyFull(recharge) },
|
||
{
|
||
icon: UserPlusIcon,
|
||
label: "用户充值 / 新用户充值",
|
||
subMetrics: [
|
||
{ caption: comparisonCaption, delta: readDelta(source, "user_recharge_delta_rate", "userRechargeDeltaRate", "recharge_delta_rate", "rechargeDeltaRate"), label: "用户充值", value: formatMoneyFull(userRecharge) },
|
||
{ caption: comparisonCaption, delta: readDelta(source, "new_user_recharge_delta_rate", "newUserRechargeDeltaRate"), label: "新用户充值", value: formatMoneyFull(newUserRecharge) }
|
||
],
|
||
trend: [
|
||
...metricTrend(dailySeries, "user_recharge_usd_minor", "用户充值", "money"),
|
||
...metricTrend(dailySeries, "new_user_recharge_usd_minor", "新用户充值", "money")
|
||
],
|
||
trendFormat: "money"
|
||
},
|
||
{ caption: comparisonCaption, delta: readDelta(source, "new_users_delta_rate", "newUsersDeltaRate"), icon: UserPlusIcon, label: "新增用户数", trend: metricTrend(dailySeries, "new_users", "新增用户数", "number"), trendFormat: "number", value: formatNumber(newUsers) },
|
||
{ caption: comparisonCaption, delta: readDelta(source, "active_users_delta_rate", "activeUsersDeltaRate"), icon: ActiveUsersIcon, label: "活跃用户", trend: metricTrend(dailySeries, "active_users", "活跃用户", "number"), trendFormat: "number", value: formatNumber(activeUsers) },
|
||
{
|
||
icon: CrownIcon,
|
||
label: "付费用户 / 新增付费用户",
|
||
subMetrics: [
|
||
{ caption: comparisonCaption, delta: readDelta(source, "paid_users_delta_rate", "paidUsersDeltaRate"), label: "付费用户", value: formatNumber(paidUsers) },
|
||
{ caption: comparisonCaption, delta: readDelta(source, "new_paid_users_delta_rate", "newPaidUsersDeltaRate"), label: "新增付费用户", value: formatNumber(newPaidUsers) }
|
||
],
|
||
trend: [
|
||
...metricTrend(dailySeries, "paid_users", "付费用户", "number"),
|
||
...metricTrend(dailySeries, "new_paid_users", "新增付费用户", "number")
|
||
],
|
||
trendFormat: "number"
|
||
},
|
||
{ caption: comparisonCaption, delta: readDelta(source, "arpu_delta_rate", "arpuDeltaRate"), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), icon: TrendIcon, label: "ARPU", trend: metricTrend(dailySeries, "arpu_usd_minor", "ARPU", "money"), trendFormat: "money", value: formatMoney(arpu) },
|
||
{ caption: comparisonCaption, delta: readDelta(source, "arppu_delta_rate", "arppuDeltaRate"), icon: StarUserIcon, label: "ARPPU", trend: metricTrend(dailySeries, "arppu_usd_minor", "ARPPU", "money"), trendFormat: "money", value: formatMoney(arppu) }
|
||
],
|
||
luckyMetrics: [
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_turnover_delta_rate", "luckyGiftTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(luckyGiftTurnover) },
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate")), label: "返奖", value: formatWholeMoney(luckyGiftPayout) },
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate")), label: "利润", value: formatWholeMoney(luckyGiftProfit) },
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_rate_delta_pp", "luckyGiftPayoutRateDeltaPp"), "pp"), label: "返奖率", value: formatPercent(source.lucky_gift_payout_rate) },
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_rate_delta_pp", "luckyGiftProfitRateDeltaPp"), "pp"), label: "利润率", value: formatPercent(source.lucky_gift_profit_rate) }
|
||
],
|
||
luckyGiftPools,
|
||
payoutDistribution,
|
||
reportCountryRows,
|
||
reportMetricSources: Array.isArray(source.report_metric_sources) ? source.report_metric_sources : [],
|
||
revenueSeries,
|
||
robotGiftKpis: [
|
||
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_normal_gift_coin", "真人房机器人普通礼物", "coin") }),
|
||
coinMetric("真人房机器人幸运礼物", realRoomRobotLuckyGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_lucky_gift_coin", "真人房机器人幸运礼物", "coin") }),
|
||
coinMetric("真人房机器人超级幸运礼物", realRoomRobotSuperGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_super_gift_coin", "真人房机器人超级幸运礼物", "coin") }),
|
||
coinMetric("真人房机器人房均礼物", realRoomRobotAvgRoomGiftCoin, "", `${formatNumber(realRoomRobotGiftRoomCount)} 个房间`, { trend: metricTrend(dailySeries, "real_room_robot_avg_room_gift_coin", "真人房机器人房均礼物", "coin") })
|
||
],
|
||
roomMetrics: [
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate")), label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) },
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate")), label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
|
||
],
|
||
sideMetrics: [
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arpu_delta_rate", "arpuDeltaRate")), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), label: "ARPU", value: formatMoney(arpu) },
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, activeUsers)) },
|
||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arppu_delta_rate", "arppuDeltaRate")), label: "ARPPU", value: formatMoney(arppu) }
|
||
],
|
||
topCountries: countryBreakdown.slice(0, 4),
|
||
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now(), timeZone)
|
||
};
|
||
}
|
||
|
||
function coinMetric(label, value, delta, caption, extra = {}) {
|
||
return { caption, delta, label, unit: "金币", unitIcon: CoinIcon, value: formatCoin(value), ...extra };
|
||
}
|
||
|
||
function captionWithDelta(label, delta) {
|
||
return `${label} ${delta}`;
|
||
}
|
||
|
||
function readNumber(source, ...keys) {
|
||
for (const key of keys) {
|
||
if (source?.[key] !== undefined && source[key] !== null) {
|
||
return numberValue(source[key]);
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function readOptionalNumber(source, ...keys) {
|
||
for (const key of keys) {
|
||
if (source?.[key] !== undefined && source[key] !== null) {
|
||
return numberValue(source[key]);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function readDelta(source, ...keys) {
|
||
const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%";
|
||
for (const key of keys) {
|
||
if (source?.[key] !== undefined && source[key] !== null && source[key] !== "") {
|
||
return formatDelta(source[key], unit);
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function deltaTone(source, ...keys) {
|
||
for (const key of keys) {
|
||
if (source?.[key] !== undefined && source[key] !== null && source[key] !== "") {
|
||
return numberValue(source[key]) < 0 ? "down" : "up";
|
||
}
|
||
}
|
||
return "up";
|
||
}
|
||
|
||
function formatDelta(value, unit) {
|
||
const numeric = numberValue(value);
|
||
const normalized = unit === "%" && Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
|
||
const prefix = normalized > 0 ? "+" : "";
|
||
return `${prefix}${normalized.toFixed(2)}${unit}`;
|
||
}
|
||
|
||
function isTodayRange(range, timeZone) {
|
||
return sameRange(range, todayRange(timeZone));
|
||
}
|
||
|
||
function normalizeRevenueSeries(source) {
|
||
if (!source || !Object.keys(source).length) {
|
||
return [];
|
||
}
|
||
if (Array.isArray(source.daily_series) && source.daily_series.length) {
|
||
return source.daily_series.map((item) => {
|
||
const coinSeller = numberValue(item.coin_seller ?? item.coin_seller_recharge_usd_minor ?? item.coinSellerRechargeUsdMinor);
|
||
const google = numberValue(item.google ?? item.google_recharge_usd_minor ?? item.googleRechargeUsdMinor);
|
||
const mifapay = numberValue(item.mifapay ?? item.mifa_pay ?? item.mifapay_recharge_usd_minor ?? item.mifaPayRechargeUsdMinor);
|
||
const channelTotal = google + mifapay + coinSeller;
|
||
const total = Math.max(numberValue(item.total ?? item.recharge_usd_minor), channelTotal);
|
||
return {
|
||
coin_seller: coinSeller,
|
||
google,
|
||
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
|
||
lineTotal: Math.max(numberValue(item.line_total ?? item.lineTotal ?? item.trend_total ?? item.trendTotal ?? total), channelTotal),
|
||
mifapay,
|
||
total
|
||
};
|
||
});
|
||
}
|
||
const coinSeller = readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
|
||
const google = numberValue(source.google_recharge_usd_minor ?? source.googleRechargeUsdMinor);
|
||
const mifapay = numberValue(source.mifapay_recharge_usd_minor ?? source.mifaPayRechargeUsdMinor);
|
||
const total = effectiveRechargeUSDMinor(source);
|
||
return [
|
||
{
|
||
coin_seller: coinSeller,
|
||
google,
|
||
label: "当前",
|
||
lineTotal: total,
|
||
mifapay,
|
||
total
|
||
}
|
||
];
|
||
}
|
||
|
||
function normalizeCountries(source, countryId) {
|
||
const raw = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown : null;
|
||
const rows = raw || (countryId ? [{
|
||
active_users: source.active_users,
|
||
arppu_usd_minor: source.arppu_usd_minor,
|
||
country: `国家 ${countryId}`,
|
||
paid_users: source.paid_users,
|
||
recharge_usd_minor: source.recharge_usd_minor,
|
||
x: 56,
|
||
y: 52
|
||
}] : []);
|
||
const totalRecharge = rows.reduce((sum, item) => sum + effectiveRechargeUSDMinor(item), 0) || 1;
|
||
return rows
|
||
.map((item, index) => ({
|
||
...normalizeCountryRow(item, index, totalRecharge)
|
||
}))
|
||
.sort((left, right) => right.recharge_usd_minor - left.recharge_usd_minor);
|
||
}
|
||
|
||
function normalizeDailyCountryBreakdown(source) {
|
||
const rows = firstArray(source?.daily_country_breakdown, source?.dailyCountryBreakdown);
|
||
if (!rows.length) {
|
||
return [];
|
||
}
|
||
const totalRecharge = rows.reduce((sum, item) => sum + effectiveRechargeUSDMinor(item), 0) || 1;
|
||
return rows.map((item, index) => ({
|
||
...normalizeCountryRow(item, index, totalRecharge),
|
||
date_label: item.label || item.stat_day || item.statDay || item.day || "",
|
||
stat_day: item.stat_day || item.statDay || item.day || ""
|
||
}));
|
||
}
|
||
|
||
function buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, dailySeries, range, source }) {
|
||
const totalRow = createReportRow(normalizeCountryRow({ ...source, country: "总计" }, 0, effectiveRechargeUSDMinor(source) || 1), {
|
||
country: "总计",
|
||
date_label: "全部",
|
||
row_type: "total"
|
||
});
|
||
if (!isMultiDayRange(range)) {
|
||
return [
|
||
totalRow,
|
||
...countryBreakdown.map((row) => createReportRow(row, { date_label: range?.start || row.stat_day || "", row_type: "country" }))
|
||
];
|
||
}
|
||
|
||
const dailyRowsByDay = groupRowsByDay(dailyCountryBreakdown);
|
||
const dailySeriesByDay = new Map(dailySeries.map((item) => [item.stat_day || item.label, item]));
|
||
const dayKeys = Array.from(new Set([
|
||
...dailySeries.map((item) => item.stat_day || item.label).filter(Boolean),
|
||
...dailyCountryBreakdown.map((item) => item.stat_day).filter(Boolean)
|
||
])).sort();
|
||
|
||
if (!dayKeys.length) {
|
||
return [
|
||
totalRow,
|
||
...countryBreakdown.map((row) => createReportRow(row, { date_label: "汇总", row_type: "country" }))
|
||
];
|
||
}
|
||
|
||
const rows = [totalRow];
|
||
for (const day of dayKeys) {
|
||
const dayCountries = dailyRowsByDay.get(day) || [];
|
||
const dayTotalSource = { ...summarizeReportRows(dayCountries), ...(dailySeriesByDay.get(day) || {}) };
|
||
const childRows = dayCountries.map((row) => createReportRow(row, {
|
||
date_label: day,
|
||
row_type: "country",
|
||
stat_day: day
|
||
}));
|
||
rows.push(createReportRow(normalizeCountryRow({ ...dayTotalSource, country: "总计" }, rows.length, effectiveRechargeUSDMinor(dayTotalSource) || 1), {
|
||
children: childRows,
|
||
country: "总计",
|
||
date_label: day,
|
||
row_type: "date_total",
|
||
stat_day: day
|
||
}));
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function createReportRow(row, extra) {
|
||
return {
|
||
...row,
|
||
...extra,
|
||
flag: extra.row_type === "country" ? row.flag : "",
|
||
row_id: [
|
||
extra.row_type,
|
||
extra.stat_day || extra.date_label || "",
|
||
row.country_id || row.country || "",
|
||
row.region_id || ""
|
||
].join(":")
|
||
};
|
||
}
|
||
|
||
function groupRowsByDay(rows) {
|
||
return rows.reduce((groups, row) => {
|
||
const day = row.stat_day || row.date_label || "";
|
||
if (!day) {
|
||
return groups;
|
||
}
|
||
if (!groups.has(day)) {
|
||
groups.set(day, []);
|
||
}
|
||
groups.get(day).push(row);
|
||
return groups;
|
||
}, new Map());
|
||
}
|
||
|
||
function summarizeReportRows(rows) {
|
||
const fields = [
|
||
"active_users",
|
||
"coin_seller_recharge_usd_minor",
|
||
"coin_seller_stock_coin",
|
||
"coin_seller_transfer_coin",
|
||
"game_payout",
|
||
"game_refund",
|
||
"game_turnover",
|
||
"gift_coin_spent",
|
||
"google_recharge_usd_minor",
|
||
"lucky_gift_payout",
|
||
"lucky_gift_turnover",
|
||
"mifapay_recharge_usd_minor",
|
||
"new_user_recharge_usd_minor",
|
||
"paid_users",
|
||
"recharge_usd_minor",
|
||
"recharge_users",
|
||
"registrations",
|
||
"salary_transfer_coin",
|
||
"super_lucky_gift_payout",
|
||
"super_lucky_gift_turnover"
|
||
];
|
||
return fields.reduce((summary, key) => {
|
||
summary[key] = rows.reduce((sum, row) => sum + numberValue(row[key]), 0);
|
||
return summary;
|
||
}, {});
|
||
}
|
||
|
||
function isMultiDayRange(range) {
|
||
return Boolean(range?.start && range?.end && range.start !== range.end);
|
||
}
|
||
|
||
function normalizeCountryRow(item, index, totalRecharge) {
|
||
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
|
||
const countryId = numberValue(item.country_id ?? item.countryId);
|
||
const country = stripCountryFlagPrefix(item.country || item.country_name || code || (countryId > 0 ? `国家 ${countryId || index + 1}` : "未知国家"));
|
||
const meta = resolveCountryMeta({ code, name: country });
|
||
const recharge = effectiveRechargeUSDMinor(item);
|
||
const activeUsers = numberValue(item.active_users);
|
||
const paidUsers = numberValue(item.paid_users);
|
||
const rechargeUsers = numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers);
|
||
const gameTurnover = readNumber(item, "game_turnover", "gameTurnover");
|
||
const gamePayout = readNumber(item, "game_payout", "gamePayout");
|
||
const gameRefund = readNumber(item, "game_refund", "gameRefund");
|
||
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit") ?? (gameTurnover - gamePayout - gameRefund);
|
||
const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
|
||
const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout");
|
||
const luckyGiftProfit = readOptionalNumber(item, "lucky_gift_profit", "luckyGiftProfit") ?? (luckyGiftTurnover - luckyGiftPayout);
|
||
const superLuckyGiftTurnover = readOptionalNumber(item, "super_lucky_gift_turnover", "superLuckyGiftTurnover");
|
||
const superLuckyGiftPayout = readOptionalNumber(item, "super_lucky_gift_payout", "superLuckyGiftPayout");
|
||
const superLuckyGiftProfit = readOptionalNumber(item, "super_lucky_gift_profit", "superLuckyGiftProfit") ??
|
||
(superLuckyGiftTurnover === null || superLuckyGiftPayout === null ? null : superLuckyGiftTurnover - superLuckyGiftPayout);
|
||
return {
|
||
active_users: activeUsers,
|
||
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
|
||
arppu_usd_minor: numberValue(item.arppu_usd_minor),
|
||
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
|
||
avg_mic_online_ms: readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs"),
|
||
coin_seller_recharge_usd_minor: readNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor"),
|
||
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
|
||
coin_seller_transfer_coin: numberValue(item.coin_seller_transfer_coin ?? item.coinSellerTransferCoin),
|
||
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
|
||
consumed_coin: readOptionalNumber(item, "consumed_coin", "consumedCoin"),
|
||
country: meta?.label || country,
|
||
country_id: countryId,
|
||
country_code: code || meta?.code || "",
|
||
flag: countryFlag(code || meta?.code),
|
||
game_payout: gamePayout,
|
||
game_profit: gameProfit,
|
||
game_profit_rate: readOptionalNumber(item, "game_profit_rate", "gameProfitRate") ?? ratio(gameProfit, gameTurnover),
|
||
game_refund: gameRefund,
|
||
game_turnover: gameTurnover,
|
||
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
|
||
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||
google_recharge_usd_minor: readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor"),
|
||
lucky_gift_payout: luckyGiftPayout,
|
||
lucky_gift_profit: luckyGiftProfit,
|
||
lucky_gift_profit_rate: readOptionalNumber(item, "lucky_gift_profit_rate", "luckyGiftProfitRate") ?? ratio(luckyGiftProfit, luckyGiftTurnover),
|
||
lucky_gift_turnover: luckyGiftTurnover,
|
||
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
|
||
mic_online_ms: readOptionalNumber(item, "mic_online_ms", "micOnlineMs"),
|
||
mifapay_recharge_usd_minor: readNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor"),
|
||
new_user_recharge_usd_minor: numberValue(item.new_user_recharge_usd_minor ?? item.newUserRechargeUsdMinor),
|
||
paid_users: paidUsers,
|
||
payer_rate: ratio(paidUsers, activeUsers),
|
||
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
|
||
recharge_usd_minor: recharge,
|
||
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
|
||
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
|
||
: ratio(rechargeUsers, activeUsers),
|
||
recharge_users: rechargeUsers,
|
||
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
|
||
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
||
share: recharge / totalRecharge,
|
||
registrations: numberValue(item.new_users ?? item.newUsers ?? item.registrations ?? item.registered_users ?? item.registeredUsers),
|
||
super_lucky_gift_payout: superLuckyGiftPayout,
|
||
super_lucky_gift_profit: superLuckyGiftProfit,
|
||
super_lucky_gift_profit_rate: readOptionalNumber(item, "super_lucky_gift_profit_rate", "superLuckyGiftProfitRate") ?? ratio(superLuckyGiftProfit, superLuckyGiftTurnover),
|
||
super_lucky_gift_turnover: superLuckyGiftTurnover,
|
||
trend_rate: numberValue(item.trend_rate ?? item.trendRate)
|
||
};
|
||
}
|
||
|
||
function effectiveRechargeUSDMinor(source) {
|
||
const direct = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor");
|
||
const channelTotal = readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor") +
|
||
readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor") +
|
||
readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
|
||
return Math.max(direct, channelTotal);
|
||
}
|
||
|
||
function effectiveUserRechargeUSDMinor(source) {
|
||
const google = readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor");
|
||
const mifapay = readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor");
|
||
const channelUserRecharge = google + mifapay;
|
||
const direct = readNumber(source, "user_recharge_usd_minor", "userRechargeUsdMinor", "recharge_usd_minor", "rechargeUsdMinor");
|
||
const coinSeller = readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
|
||
return Math.max(channelUserRecharge, Math.max(0, direct - coinSeller));
|
||
}
|
||
|
||
function normalizeDailySeries(source) {
|
||
if (!Array.isArray(source?.daily_series) || !source.daily_series.length) {
|
||
return [];
|
||
}
|
||
return source.daily_series.map((item) => {
|
||
const google = readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor", "google");
|
||
const mifapay = readNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor", "mifapay");
|
||
const coinSeller = readNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor", "coin_seller");
|
||
const channelTotal = google + mifapay + coinSeller;
|
||
const recharge = Math.max(effectiveRechargeUSDMinor(item), channelTotal);
|
||
const userRecharge = Math.max(google + mifapay, Math.max(0, readNumber(item, "user_recharge_usd_minor", "userRechargeUsdMinor", "recharge_usd_minor", "rechargeUsdMinor") - coinSeller));
|
||
const gameTurnover = readNumber(item, "game_turnover", "gameTurnover");
|
||
const gameProfit = readNumber(item, "game_profit", "gameProfit");
|
||
const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
|
||
const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout");
|
||
return {
|
||
active_users: readNumber(item, "active_users", "activeUsers"),
|
||
arppu_usd_minor: readNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
|
||
arpu_usd_minor: readNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
||
coin_seller: coinSeller,
|
||
coin_seller_recharge_usd_minor: coinSeller,
|
||
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
|
||
coin_seller_transfer_coin: readNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
|
||
game_profit: gameProfit,
|
||
game_turnover: gameTurnover,
|
||
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||
google,
|
||
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
|
||
lineTotal: Math.max(readNumber(item, "line_total", "lineTotal", "trend_total", "trendTotal", "total"), recharge, channelTotal),
|
||
lucky_gift_payout: luckyGiftPayout,
|
||
lucky_gift_profit: readNumber(item, "lucky_gift_profit", "luckyGiftProfit") || (luckyGiftTurnover - luckyGiftPayout),
|
||
lucky_gift_turnover: luckyGiftTurnover,
|
||
mifapay,
|
||
new_paid_users: readNumber(item, "new_paid_users", "newPaidUsers"),
|
||
new_user_recharge_usd_minor: readNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
|
||
new_users: readNumber(item, "new_users", "newUsers"),
|
||
paid_users: readNumber(item, "paid_users", "paidUsers"),
|
||
recharge_usd_minor: recharge,
|
||
recharge_users: readNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"),
|
||
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
||
stat_day: item.stat_day || item.statDay || item.day || item.label || "",
|
||
total: recharge,
|
||
user_recharge_usd_minor: userRecharge
|
||
};
|
||
});
|
||
}
|
||
|
||
function metricTrend(series, key, name, format = "number") {
|
||
const data = series.map((item) => ({
|
||
label: item.label || item.stat_day || "-",
|
||
value: format === "money" ? numberValue(item[key]) / 100 : numberValue(item[key])
|
||
}));
|
||
return data.length ? [{ data, name }] : [];
|
||
}
|
||
|
||
function normalizeGiftRanking(source) {
|
||
if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) {
|
||
return source.gift_ranking.map((item) => ({
|
||
name: item.name || item.gift_name || item.gift_id || "-",
|
||
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
||
value: numberValue(item.value ?? item.amount ?? item.coin)
|
||
}));
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function normalizeDistribution(source) {
|
||
if (Array.isArray(source.payout_distribution) && source.payout_distribution.length) {
|
||
return source.payout_distribution.map((item) => ({ name: item.name || "-", value: numberValue(item.value) }));
|
||
}
|
||
if (!source || !Object.keys(source).length) {
|
||
return [];
|
||
}
|
||
return [
|
||
{ name: "幸运礼物返奖", value: numberValue(source.lucky_gift_payout) },
|
||
{ name: "幸运礼物利润", value: Math.max(numberValue(source.lucky_gift_profit), 0) }
|
||
];
|
||
}
|
||
|
||
function normalizeLuckyGiftPools(source) {
|
||
const raw = firstArray(source?.lucky_gift_pools, source?.luckyGiftPools, source?.lucky_gift_pool_stats, source?.luckyGiftPoolStats, source?.pool_breakdown);
|
||
return raw
|
||
.map((item) => {
|
||
const turnover = readNumber(item, "turnover_coin", "turnoverCoin", "turnover", "total_spent_coins", "totalSpentCoins", "lucky_gift_turnover", "luckyGiftTurnover");
|
||
const payout = readNumber(item, "payout_coin", "payoutCoin", "payout", "total_reward_coins", "totalRewardCoins", "lucky_gift_payout", "luckyGiftPayout");
|
||
const rawProfit = item.profit_coin ?? item.profitCoin ?? item.profit ?? item.lucky_gift_profit ?? item.luckyGiftProfit;
|
||
const profit = rawProfit === undefined || rawProfit === null ? turnover - payout : numberValue(rawProfit);
|
||
return {
|
||
payout,
|
||
payout_rate: numberValue(item.payout_rate ?? item.payoutRate ?? ratio(payout, turnover)),
|
||
pool_id: String(item.pool_id ?? item.poolId ?? item.id ?? "-").trim() || "-",
|
||
profit,
|
||
profit_rate: numberValue(item.profit_rate ?? item.profitRate ?? ratio(profit, turnover)),
|
||
turnover
|
||
};
|
||
})
|
||
.sort((left, right) => right.turnover - left.turnover || left.pool_id.localeCompare(right.pool_id));
|
||
}
|
||
|
||
function summarizeLuckyGiftPools(items) {
|
||
return items.reduce((summary, item) => ({
|
||
payout: summary.payout + item.payout,
|
||
profit: summary.profit + item.profit,
|
||
turnover: summary.turnover + item.turnover
|
||
}), { payout: 0, profit: 0, turnover: 0 });
|
||
}
|
||
|
||
function firstArray(...values) {
|
||
return values.find((value) => Array.isArray(value)) || [];
|
||
}
|
||
|
||
function normalizeGameRanking(source) {
|
||
if (Array.isArray(source.game_ranking) && source.game_ranking.length) {
|
||
return source.game_ranking.map((item) => ({
|
||
game_id: item.game_id || item.gameId || "-",
|
||
game_label: gameDisplayLabel(item),
|
||
game_name: item.game_name || item.gameName || item.name || "",
|
||
platform_code: item.platform_code || item.platformCode || "",
|
||
profit_rate: numberValue(item.profit_rate ?? item.profitRate),
|
||
turnover_coin: numberValue(item.turnover_coin ?? item.turnoverCoin)
|
||
}));
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function gameDisplayLabel(item) {
|
||
const id = String(item.game_id || item.gameId || "").trim();
|
||
const name = String(item.game_name || item.gameName || item.name || "").trim();
|
||
if (name && id && name !== id) {
|
||
return `${name}(${id})`;
|
||
}
|
||
return name || id || "-";
|
||
}
|
||
|
||
function localizeSeriesLabel(label) {
|
||
const match = String(label).match(/^May\s+(\d{1,2})$/i);
|
||
if (match) {
|
||
return `5月${match[1]}日`;
|
||
}
|
||
return label;
|
||
}
|