feat(profitability): hybrid actual fees for Shopee/TikTok with conservative 30% fallback (#13)
Co-authored-by: Your Name <you@example.com> Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
1 parent
b8bb1d4617
commit
dbc6c513db
2 files changed
+373
-152
No files matched your search
@@ -60,6 +60,15 @@ function parseOrderDate(dateStr: string | Date | null | undefined): Date | null
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
const toNum = (val: any): number => {
|
||||
if (val === null || val === undefined || val === "") return 0;
|
||||
const s = String(val).replace(/[^0-9.\-]/g, "");
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
|
||||
const DEFAULT_FALLBACK_FEE_PERCENT = 30.0; // 30% conservative platform deduction fallback
|
||||
|
||||
export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -313,152 +322,226 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
orderBy: { totalRevenue: "desc" },
|
||||
});
|
||||
|
||||
const CHANNEL_FEES: Record<string, { commissionPercent: number; servicePercent: number; paymentPercent: number }> = {
|
||||
shopee: { commissionPercent: 8.0, servicePercent: 5.0, paymentPercent: 3.21 }, // ~16.21%
|
||||
lazada: { commissionPercent: 6.0, servicePercent: 4.0, paymentPercent: 3.21 }, // ~13.21%
|
||||
tiktok: { commissionPercent: 6.0, servicePercent: 4.5, paymentPercent: 3.0 }, // ~13.5%
|
||||
};
|
||||
|
||||
// 2. If a specific Date Range is selected, calculate dynamic sales from Orders
|
||||
let dynamicSalesMap: Map<number, { soldQty: number; revenue: number }> | null = null;
|
||||
|
||||
if (dateRange.start) {
|
||||
dynamicSalesMap = new Map();
|
||||
const start = dateRange.start;
|
||||
const end = dateRange.end || new Date();
|
||||
|
||||
// Look up helper: map marketplace item keys to marketplaceProduct.id
|
||||
const mpLookup = new Map<string, number>();
|
||||
for (const mp of mpProducts) {
|
||||
const key = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`;
|
||||
mpLookup.set(key, mp.id);
|
||||
if (mp.marketplaceSku) {
|
||||
mpLookup.set(`${mp.channel}:::${mp.marketplaceSku}`, mp.id);
|
||||
}
|
||||
// Look up helper: map marketplace item keys to marketplaceProduct.id
|
||||
const mpLookup = new Map<string, number>();
|
||||
for (const mp of mpProducts) {
|
||||
const key = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`;
|
||||
mpLookup.set(key, mp.id);
|
||||
if (mp.marketplaceSku) {
|
||||
mpLookup.set(`${mp.channel}:::${mp.marketplaceSku}`, mp.id);
|
||||
}
|
||||
}
|
||||
|
||||
// A. TikTok Orders in Range
|
||||
if (channel === "all" || channel === "tiktok") {
|
||||
const ttOrders = await prisma.tiktokOrder.findMany({
|
||||
where: { createdTime: { not: null } },
|
||||
select: {
|
||||
createdTime: true,
|
||||
shop: true,
|
||||
items: {
|
||||
select: {
|
||||
sellerSku: true,
|
||||
skuId: true,
|
||||
productName: true,
|
||||
variation: true,
|
||||
quantity: true,
|
||||
subtotalAfterDiscount: true,
|
||||
unitOriginalPrice: true,
|
||||
},
|
||||
// 2. Dynamic Order Aggregator (Actual Fee Extraction with 30% Fallback)
|
||||
interface DynamicSalesEntry {
|
||||
soldQty: number;
|
||||
revenue: number;
|
||||
actualFeeSum: number;
|
||||
fallbackFeeSum: number;
|
||||
actualCount: number;
|
||||
fallbackCount: number;
|
||||
}
|
||||
|
||||
const dynamicSalesMap = new Map<number, DynamicSalesEntry>();
|
||||
const start = dateRange.start;
|
||||
const end = dateRange.end || new Date();
|
||||
|
||||
// A. TikTok Orders
|
||||
if (channel === "all" || channel === "tiktok") {
|
||||
const ttOrders = await prisma.tiktokOrder.findMany({
|
||||
where: { createdTime: { not: null } },
|
||||
select: {
|
||||
orderId: true,
|
||||
createdTime: true,
|
||||
shop: true,
|
||||
orderAmount: true,
|
||||
commissionFee: true,
|
||||
affiliateFee: true,
|
||||
netSettlement: true,
|
||||
isSettled: true,
|
||||
items: {
|
||||
select: {
|
||||
sellerSku: true,
|
||||
skuId: true,
|
||||
productName: true,
|
||||
variation: true,
|
||||
quantity: true,
|
||||
subtotalAfterDiscount: true,
|
||||
unitOriginalPrice: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
for (const order of ttOrders) {
|
||||
const orderDate = parseOrderDate(order.createdTime);
|
||||
if (!orderDate || orderDate < start || orderDate > end) continue;
|
||||
for (const order of ttOrders) {
|
||||
const orderDate = parseOrderDate(order.createdTime);
|
||||
if (start && (!orderDate || orderDate < start || orderDate > end)) continue;
|
||||
|
||||
for (const item of order.items) {
|
||||
const sku = item.sellerSku || item.skuId || "";
|
||||
const key1 = `tiktok:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variation || ""}:::${sku}`;
|
||||
const key2 = `tiktok:::${sku}`;
|
||||
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
||||
if (!mpId) continue;
|
||||
const orderAmount = order.orderAmount || 0;
|
||||
// Check actual deduction from settlement or commission
|
||||
const hasSettlement = order.isSettled && order.netSettlement > 0;
|
||||
const actualOrderFee = hasSettlement
|
||||
? Math.max(0, orderAmount - order.netSettlement)
|
||||
: ((order.commissionFee || 0) + (order.affiliateFee || 0));
|
||||
const isActual = actualOrderFee > 0;
|
||||
|
||||
const qty = item.quantity || 1;
|
||||
const rev = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
|
||||
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
|
||||
cur.soldQty += qty;
|
||||
cur.revenue += rev;
|
||||
dynamicSalesMap.set(mpId, cur);
|
||||
for (const item of order.items) {
|
||||
const sku = item.sellerSku || item.skuId || "";
|
||||
const key1 = `tiktok:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variation || ""}:::${sku}`;
|
||||
const key2 = `tiktok:::${sku}`;
|
||||
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
||||
if (!mpId) continue;
|
||||
|
||||
const qty = item.quantity || 1;
|
||||
const rev = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
|
||||
|
||||
const cur = dynamicSalesMap.get(mpId) || {
|
||||
soldQty: 0,
|
||||
revenue: 0,
|
||||
actualFeeSum: 0,
|
||||
fallbackFeeSum: 0,
|
||||
actualCount: 0,
|
||||
fallbackCount: 0,
|
||||
};
|
||||
cur.soldQty += qty;
|
||||
cur.revenue += rev;
|
||||
|
||||
if (isActual) {
|
||||
// Proportional share of actual order deduction
|
||||
const share = orderAmount > 0 ? (rev / orderAmount) : 1;
|
||||
cur.actualFeeSum += actualOrderFee * share;
|
||||
cur.actualCount += 1;
|
||||
} else {
|
||||
// Fallback 30%
|
||||
cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100);
|
||||
cur.fallbackCount += 1;
|
||||
}
|
||||
dynamicSalesMap.set(mpId, cur);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// B. Shopee Orders in Range
|
||||
if (channel === "all" || channel === "shopee") {
|
||||
const spOrders = await prisma.shopeeOrder.findMany({
|
||||
where: { orderCreatedAt: { not: null } },
|
||||
select: {
|
||||
orderCreatedAt: true,
|
||||
shop: true,
|
||||
items: {
|
||||
select: {
|
||||
parentSku: true,
|
||||
skuRef: true,
|
||||
productName: true,
|
||||
variantName: true,
|
||||
quantity: true,
|
||||
totalPayment: true,
|
||||
dealPrice: true,
|
||||
},
|
||||
// B. Shopee Orders
|
||||
if (channel === "all" || channel === "shopee") {
|
||||
const spOrders = await prisma.shopeeOrder.findMany({
|
||||
where: { orderCreatedAt: { not: null } },
|
||||
select: {
|
||||
orderId: true,
|
||||
orderCreatedAt: true,
|
||||
shop: true,
|
||||
totalAmount: true,
|
||||
items: {
|
||||
select: {
|
||||
parentSku: true,
|
||||
skuRef: true,
|
||||
productName: true,
|
||||
variantName: true,
|
||||
quantity: true,
|
||||
totalPayment: true,
|
||||
dealPrice: true,
|
||||
rawData: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
for (const order of spOrders) {
|
||||
const orderDate = parseOrderDate(order.orderCreatedAt);
|
||||
if (!orderDate || orderDate < start || orderDate > end) continue;
|
||||
for (const order of spOrders) {
|
||||
const orderDate = parseOrderDate(order.orderCreatedAt);
|
||||
if (start && (!orderDate || orderDate < start || orderDate > end)) continue;
|
||||
|
||||
for (const item of order.items) {
|
||||
const sku = item.skuRef || item.parentSku || "";
|
||||
const key1 = `shopee:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variantName || ""}:::${sku}`;
|
||||
const key2 = `shopee:::${sku}`;
|
||||
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
||||
if (!mpId) continue;
|
||||
// Shopee Order-level actual fee from rawData (first item has order-level fees)
|
||||
const firstRaw = (order.items[0]?.rawData as any) || {};
|
||||
const comm = toNum(firstRaw["ค่าคอมมิชชั่น"]);
|
||||
const service = toNum(firstRaw["ค่าบริการ"]);
|
||||
const tx = toNum(firstRaw["Transaction Fee"]);
|
||||
const voucher = toNum(firstRaw["โค้ดส่วนลดชำระโดยผู้ขาย"]);
|
||||
const orderActualFee = comm + service + tx + voucher;
|
||||
const isActual = orderActualFee > 0;
|
||||
const orderTotalRev = order.totalAmount || order.items.reduce((sum, it) => sum + (it.totalPayment > 0 ? it.totalPayment : (it.dealPrice * it.quantity)), 0);
|
||||
|
||||
const qty = item.quantity || 1;
|
||||
const rev = item.totalPayment > 0 ? item.totalPayment : (item.dealPrice * qty);
|
||||
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
|
||||
cur.soldQty += qty;
|
||||
cur.revenue += rev;
|
||||
dynamicSalesMap.set(mpId, cur);
|
||||
for (const item of order.items) {
|
||||
const sku = item.skuRef || item.parentSku || "";
|
||||
const key1 = `shopee:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variantName || ""}:::${sku}`;
|
||||
const key2 = `shopee:::${sku}`;
|
||||
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
||||
if (!mpId) continue;
|
||||
|
||||
const qty = item.quantity || 1;
|
||||
const rev = item.totalPayment > 0 ? item.totalPayment : (item.dealPrice * qty);
|
||||
|
||||
const cur = dynamicSalesMap.get(mpId) || {
|
||||
soldQty: 0,
|
||||
revenue: 0,
|
||||
actualFeeSum: 0,
|
||||
fallbackFeeSum: 0,
|
||||
actualCount: 0,
|
||||
fallbackCount: 0,
|
||||
};
|
||||
cur.soldQty += qty;
|
||||
cur.revenue += rev;
|
||||
|
||||
if (isActual) {
|
||||
const share = orderTotalRev > 0 ? (rev / orderTotalRev) : 1;
|
||||
cur.actualFeeSum += orderActualFee * share;
|
||||
cur.actualCount += 1;
|
||||
} else {
|
||||
// Fallback 30%
|
||||
cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100);
|
||||
cur.fallbackCount += 1;
|
||||
}
|
||||
dynamicSalesMap.set(mpId, cur);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// C. Lazada Orders in Range
|
||||
if (channel === "all" || channel === "lazada") {
|
||||
const lzOrders = await prisma.lazadaOrder.findMany({
|
||||
where: { createTime: { not: null } },
|
||||
select: {
|
||||
createTime: true,
|
||||
shop: true,
|
||||
items: {
|
||||
select: {
|
||||
sellerSku: true,
|
||||
lazadaSku: true,
|
||||
itemName: true,
|
||||
variation: true,
|
||||
paidPrice: true,
|
||||
unitPrice: true,
|
||||
},
|
||||
// C. Lazada Orders
|
||||
if (channel === "all" || channel === "lazada") {
|
||||
const lzOrders = await prisma.lazadaOrder.findMany({
|
||||
where: { createTime: { not: null } },
|
||||
select: {
|
||||
createTime: true,
|
||||
shop: true,
|
||||
items: {
|
||||
select: {
|
||||
sellerSku: true,
|
||||
lazadaSku: true,
|
||||
itemName: true,
|
||||
variation: true,
|
||||
paidPrice: true,
|
||||
unitPrice: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
for (const order of lzOrders) {
|
||||
const orderDate = parseOrderDate(order.createTime);
|
||||
if (!orderDate || orderDate < start || orderDate > end) continue;
|
||||
for (const order of lzOrders) {
|
||||
const orderDate = parseOrderDate(order.createTime);
|
||||
if (start && (!orderDate || orderDate < start || orderDate > end)) continue;
|
||||
|
||||
for (const item of order.items) {
|
||||
const sku = item.sellerSku || item.lazadaSku || "";
|
||||
const key1 = `lazada:::${order.shop || "hom-aroma"}:::${item.itemName}:::${item.variation || ""}:::${sku}`;
|
||||
const key2 = `lazada:::${sku}`;
|
||||
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
||||
if (!mpId) continue;
|
||||
for (const item of order.items) {
|
||||
const sku = item.sellerSku || item.lazadaSku || "";
|
||||
const key1 = `lazada:::${order.shop || "hom-aroma"}:::${item.itemName}:::${item.variation || ""}:::${sku}`;
|
||||
const key2 = `lazada:::${sku}`;
|
||||
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
||||
if (!mpId) continue;
|
||||
|
||||
const qty = 1;
|
||||
const rev = item.paidPrice > 0 ? item.paidPrice : item.unitPrice;
|
||||
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
|
||||
cur.soldQty += qty;
|
||||
cur.revenue += rev;
|
||||
dynamicSalesMap.set(mpId, cur);
|
||||
}
|
||||
const qty = 1;
|
||||
const rev = item.paidPrice > 0 ? item.paidPrice : item.unitPrice;
|
||||
|
||||
const cur = dynamicSalesMap.get(mpId) || {
|
||||
soldQty: 0,
|
||||
revenue: 0,
|
||||
actualFeeSum: 0,
|
||||
fallbackFeeSum: 0,
|
||||
actualCount: 0,
|
||||
fallbackCount: 0,
|
||||
};
|
||||
cur.soldQty += qty;
|
||||
cur.revenue += rev;
|
||||
// Lazada has no settlement import yet -> 30% fallback
|
||||
cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100);
|
||||
cur.fallbackCount += 1;
|
||||
dynamicSalesMap.set(mpId, cur);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,25 +552,58 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
let totalBOMCost = 0;
|
||||
let totalEstNetProfit = 0;
|
||||
let totalUnitsSold = 0;
|
||||
let totalPlatformFee = 0;
|
||||
let totalActualFeeSum = 0;
|
||||
let totalFallbackFeeSum = 0;
|
||||
let totalActualOrdersCount = 0;
|
||||
let totalFallbackOrdersCount = 0;
|
||||
|
||||
const channelStats: Record<string, { soldQty: number; revenue: number; bomCost: number; netProfit: number }> = {
|
||||
shopee: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 },
|
||||
lazada: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 },
|
||||
tiktok: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 },
|
||||
const channelStats: Record<string, { soldQty: number; revenue: number; platformFee: number; bomCost: number; netProfit: number }> = {
|
||||
shopee: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
|
||||
lazada: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
|
||||
tiktok: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
|
||||
};
|
||||
|
||||
const mappedItems: any[] = [];
|
||||
|
||||
for (const item of mpProducts) {
|
||||
let soldQty = item.totalSoldQty;
|
||||
let rev = item.totalRevenue;
|
||||
let soldQty = 0;
|
||||
let rev = 0;
|
||||
let platformFee = 0;
|
||||
let feeSource: "actual" | "hybrid" | "fallback_30pct" = "fallback_30pct";
|
||||
let actualFeePart = 0;
|
||||
let fallbackFeePart = 0;
|
||||
|
||||
// If dynamic date range active, use filtered order numbers
|
||||
if (dynamicSalesMap) {
|
||||
const dyn = dynamicSalesMap.get(item.id);
|
||||
if (!dyn || dyn.soldQty === 0) continue; // Skip items with no sales in this date range
|
||||
const dyn = dynamicSalesMap.get(item.id);
|
||||
|
||||
if (dyn && dyn.soldQty > 0) {
|
||||
soldQty = dyn.soldQty;
|
||||
rev = dyn.revenue;
|
||||
actualFeePart = dyn.actualFeeSum;
|
||||
fallbackFeePart = dyn.fallbackFeeSum;
|
||||
platformFee = Math.round((dyn.actualFeeSum + dyn.fallbackFeeSum) * 100) / 100;
|
||||
|
||||
if (dyn.actualCount > 0 && dyn.fallbackCount === 0) {
|
||||
feeSource = "actual";
|
||||
} else if (dyn.actualCount > 0 && dyn.fallbackCount > 0) {
|
||||
feeSource = "hybrid";
|
||||
} else {
|
||||
feeSource = "fallback_30pct";
|
||||
}
|
||||
totalActualOrdersCount += dyn.actualCount;
|
||||
totalFallbackOrdersCount += dyn.fallbackCount;
|
||||
} else if (!start) {
|
||||
// All-time fallback from MarketplaceProduct aggregate if no order details
|
||||
if (item.totalSoldQty === 0 && item.totalRevenue === 0) continue;
|
||||
soldQty = item.totalSoldQty;
|
||||
rev = item.totalRevenue;
|
||||
platformFee = Math.round(rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100) * 100) / 100;
|
||||
fallbackFeePart = platformFee;
|
||||
feeSource = "fallback_30pct";
|
||||
totalFallbackOrdersCount += 1;
|
||||
} else {
|
||||
// Date range filtered and no sales in this period
|
||||
continue;
|
||||
}
|
||||
|
||||
const v = item.masterVariant!;
|
||||
@@ -498,13 +614,11 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
|
||||
const itemBomCost = Math.round(unitBomCost * soldQty * 100) / 100;
|
||||
const ch = item.channel.toLowerCase();
|
||||
const feeConfig = CHANNEL_FEES[ch] || { commissionPercent: 7.0, servicePercent: 4.0, paymentPercent: 3.0 };
|
||||
const totalFeePercent = feeConfig.commissionPercent + feeConfig.servicePercent + feeConfig.paymentPercent;
|
||||
|
||||
const estPlatformFee = Math.round(rev * (totalFeePercent / 100) * 100) / 100;
|
||||
const netRevenue = Math.round((rev - estPlatformFee) * 100) / 100;
|
||||
const netRevenue = Math.round((rev - platformFee) * 100) / 100;
|
||||
const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100;
|
||||
const netMarginPercent = rev > 0 ? Math.round((netProfit / rev) * 1000) / 10 : 0;
|
||||
const effectiveFeePercent = rev > 0 ? Math.round((platformFee / rev) * 1000) / 10 : DEFAULT_FALLBACK_FEE_PERCENT;
|
||||
|
||||
const avgSellingPrice = soldQty > 0 ? Math.round((rev / soldQty) * 100) / 100 : item.avgPrice;
|
||||
const directPrice = v.price || avgSellingPrice || 0;
|
||||
@@ -512,14 +626,18 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0;
|
||||
|
||||
totalGrossRevenue += rev;
|
||||
totalPlatformFee += platformFee;
|
||||
totalEstNetRevenue += netRevenue;
|
||||
totalBOMCost += itemBomCost;
|
||||
totalEstNetProfit += netProfit;
|
||||
totalUnitsSold += soldQty;
|
||||
totalActualFeeSum += actualFeePart;
|
||||
totalFallbackFeeSum += fallbackFeePart;
|
||||
|
||||
if (channelStats[ch]) {
|
||||
channelStats[ch].soldQty += soldQty;
|
||||
channelStats[ch].revenue += rev;
|
||||
channelStats[ch].platformFee += platformFee;
|
||||
channelStats[ch].bomCost += itemBomCost;
|
||||
channelStats[ch].netProfit += netProfit;
|
||||
}
|
||||
@@ -540,8 +658,9 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
categoryName: v.product?.category?.name || "Uncategorized",
|
||||
unitBomCost: Math.round(unitBomCost * 100) / 100,
|
||||
totalBomCost: itemBomCost,
|
||||
totalFeePercent,
|
||||
estPlatformFee,
|
||||
platformFee,
|
||||
effectiveFeePercent,
|
||||
feeSource, // "actual" | "hybrid" | "fallback_30pct"
|
||||
netRevenue,
|
||||
netProfit,
|
||||
netMarginPercent,
|
||||
@@ -563,6 +682,8 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
|
||||
const overallNetMargin =
|
||||
totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0;
|
||||
const effectiveFeeRate =
|
||||
totalGrossRevenue > 0 ? Math.round((totalPlatformFee / totalGrossRevenue) * 1000) / 10 : DEFAULT_FALLBACK_FEE_PERCENT;
|
||||
|
||||
return {
|
||||
periodInfo: {
|
||||
@@ -572,14 +693,25 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null,
|
||||
daysCount: dateRange.daysCount,
|
||||
},
|
||||
feePolicy: {
|
||||
mode: "hybrid_actual_with_30pct_fallback",
|
||||
fallbackPercent: DEFAULT_FALLBACK_FEE_PERCENT,
|
||||
description: "Shopee uses actual fees from report rawData. TikTok uses actual net settlement & fees. Any unmapped or non-settled order falls back to 30% platform deduction.",
|
||||
},
|
||||
kpis: {
|
||||
totalMappedProducts: mappedItems.length,
|
||||
totalUnitsSold,
|
||||
totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100,
|
||||
totalPlatformFee: Math.round(totalPlatformFee * 100) / 100,
|
||||
totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100,
|
||||
totalBOMCost: Math.round(totalBOMCost * 100) / 100,
|
||||
totalEstNetProfit: Math.round(totalEstNetProfit * 100) / 100,
|
||||
overallNetMargin,
|
||||
effectiveFeeRate,
|
||||
actualFeeAmount: Math.round(totalActualFeeSum * 100) / 100,
|
||||
fallbackFeeAmount: Math.round(totalFallbackFeeSum * 100) / 100,
|
||||
actualOrdersCount: totalActualOrdersCount,
|
||||
fallbackOrdersCount: totalFallbackOrdersCount,
|
||||
channelStats,
|
||||
},
|
||||
items: filtered,
|
||||
|
||||
Reference in new issue
Block a user