From 0fe7c5b9c1883fece173c5e90cb7215ef35957ab Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 18 Oct 2026 19:30:31 +0700 Subject: [PATCH] feat(profitability): hybrid actual fees for Shopee/TikTok with conservative 30% fallback --- .../profitability/OmnichannelMarginPage.tsx | 119 ++++- apps/backend/src/routes/profitability.ts | 406 ++++++++++++------ 2 files changed, 373 insertions(+), 152 deletions(-) diff --git a/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx b/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx index 31d8933..4a00f95 100644 --- a/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx +++ b/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx @@ -8,6 +8,8 @@ import { Coins, RefreshCw, Calendar, + Receipt, + ShieldCheck, } from 'lucide-react'; import { API_BASE } from '@homaroma/types'; import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs'; @@ -50,8 +52,9 @@ interface OmnichannelItem { categoryName: string; unitBomCost: number; totalBomCost: number; - totalFeePercent: number; - estPlatformFee: number; + platformFee: number; + effectiveFeePercent: number; + feeSource: 'actual' | 'hybrid' | 'fallback_30pct'; netRevenue: number; netProfit: number; netMarginPercent: number; @@ -62,6 +65,7 @@ interface OmnichannelItem { interface ChannelStat { soldQty: number; revenue: number; + platformFee: number; bomCost: number; netProfit: number; } @@ -78,10 +82,16 @@ interface OmnichannelKpis { totalMappedProducts: number; totalUnitsSold: number; totalGrossRevenue: number; + totalPlatformFee: number; totalEstNetRevenue: number; totalBOMCost: number; totalEstNetProfit: number; overallNetMargin: number; + effectiveFeeRate: number; + actualFeeAmount: number; + fallbackFeeAmount: number; + actualOrdersCount: number; + fallbackOrdersCount: number; channelStats: Record; } @@ -89,6 +99,7 @@ export default function OmnichannelMarginPage() { const [loading, setLoading] = useState(true); const [data, setData] = useState<{ periodInfo: PeriodInfo; + feePolicy?: { mode: string; fallbackPercent: number; description: string }; kpis: OmnichannelKpis; items: OmnichannelItem[]; } | null>(null); @@ -163,9 +174,11 @@ export default function OmnichannelMarginPage() { 'Units Sold', 'Avg Price', 'Total Revenue', + 'Platform Fee (THB)', + 'Effective Fee %', + 'Fee Source', 'Unit BOM Cost', 'Total BOM Cost', - 'Platform Fee %', 'Net Revenue', 'Net Profit', 'Net Margin %', @@ -184,9 +197,11 @@ export default function OmnichannelMarginPage() { i.totalSoldQty, i.avgSellingPrice, i.totalRevenue, + i.platformFee, + `${i.effectiveFeePercent}%`, + i.feeSource, i.unitBomCost, i.totalBomCost, - `${i.totalFeePercent}%`, i.netRevenue, i.netProfit, `${i.netMarginPercent}%`, @@ -288,19 +303,58 @@ export default function OmnichannelMarginPage() { - {/* ── Executive KPI Cards ────────────────────────────────────────── */} -
+ {/* ── Fee Policy Notice Banner ─────────────────────────────────── */} +
+
+
+ +
+
+ Hybrid Fee Policy (Actual + 30% Fallback):{' '} + + Shopee ดึงค่าธรรมเนียมจริงจากรายงานคำสั่งซื้อ, TikTok คำนวณจากยอดโอนจริง (Settlement) + สำหรับออเดอร์ที่ยังไม่มีข้อมูลค่าธรรมเนียม จะใช้ตัวคูณปลอดภัย{' '} + 30% Fallback + +
+
+
+ + Actual: {fmtNum(kpis?.actualOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.actualFeeAmount)}) + + + 30% Fallback: {fmtNum(kpis?.fallbackOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.fallbackFeeAmount)}) + +
+
+ + {/* ── Executive KPI Cards (5 Cards) ──────────────────────────────── */} +
{/* Total Gross Revenue */}
- Gross Revenue ({periodInfo?.label || 'All'}) + Gross Revenue
{fmtMoney(kpis?.totalGrossRevenue)}

- {fmtNum(kpis?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} รายการ SKU) + {fmtNum(kpis?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} SKU) +

+
+ + {/* Total Platform Fee */} +
+
+ Platform Deductions +
+ +
+
+
{fmtMoney(kpis?.totalPlatformFee)}
+

+ เฉลี่ยหัก ~{kpis?.effectiveFeeRate ?? 30}% ของยอดขาย

@@ -314,28 +368,28 @@ export default function OmnichannelMarginPage() {
{fmtMoney(kpis?.totalBOMCost)}

- ต้นทุนวัตถุดิบจริงสะสมของสินค้าที่ขายได้ในช่วงเวลานี้ + ต้นทุนวัตถุดิบจริงสะสมของสินค้า

{/* Total Est Net Profit */}
- Est. Net Profit (หลังหัก Fee) + Est. Net Profit
{fmtMoney(kpis?.totalEstNetProfit)}

- หักทั้งค่าธรรมเนียม Platform และต้นทุน BOM + หักค่า Platform และต้นทุน BOM แล้ว

{/* Overall Net Margin % */}
- Net Margin % ({periodInfo?.label || 'All'}) + Net Margin %
@@ -380,6 +434,12 @@ export default function OmnichannelMarginPage() {
+ Platform Fee: + + {fmtMoney(kpis.channelStats.shopee?.platformFee)} + +
+
Net Profit: {fmtMoney(kpis.channelStats.shopee?.netProfit)} @@ -414,6 +474,12 @@ export default function OmnichannelMarginPage() {
+ Platform Fee: + + {fmtMoney(kpis.channelStats.lazada?.platformFee)} + +
+
Net Profit: {fmtMoney(kpis.channelStats.lazada?.netProfit)} @@ -448,6 +514,12 @@ export default function OmnichannelMarginPage() {
+ Platform Fee: + + {fmtMoney(kpis.channelStats.tiktok?.platformFee)} + +
+
Net Profit: {fmtMoney(kpis.channelStats.tiktok?.netProfit)} @@ -469,7 +541,7 @@ export default function OmnichannelMarginPage() { ขายได้ ({periodInfo?.label || 'All'}) ราคาขายเฉลี่ย ต้นทุน BOM (Unit) - หัก Fee (%) + หัก Fee (%) & ที่มา Net Rev / Unit Net Profit / Unit Net Margin % @@ -528,8 +600,25 @@ export default function OmnichannelMarginPage() { {fmtMoney(item.unitBomCost)} - - ~{item.totalFeePercent}% + +
+ ~{item.effectiveFeePercent}% +
+
+ {item.feeSource === 'actual' ? ( + + Actual + + ) : item.feeSource === 'hybrid' ? ( + + Hybrid + + ) : ( + + 30% Fallback + + )} +
{fmtMoney(unitNetRevenue)} diff --git a/apps/backend/src/routes/profitability.ts b/apps/backend/src/routes/profitability.ts index 9f81829..a0637bf 100644 --- a/apps/backend/src/routes/profitability.ts +++ b/apps/backend/src/routes/profitability.ts @@ -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 = { - 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 | 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(); - 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(); + 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(); + 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 = { - 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 = { + 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, -- 2.43.0