feat(profitability): hybrid actual fees for Shopee/TikTok with conservative 30% fallback #13

Merged
admin merged 1 commits from feat/actual-fees-30pct-fallback into master 2026-10-18 14:53:32 +00:00
2 changed files with 275 additions and 54 deletions

No files matched your search

@@ -8,6 +8,8 @@ import {
Coins, Coins,
RefreshCw, RefreshCw,
Calendar, Calendar,
Receipt,
ShieldCheck,
} from 'lucide-react'; } from 'lucide-react';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs'; import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
@@ -50,8 +52,9 @@ interface OmnichannelItem {
categoryName: string; categoryName: string;
unitBomCost: number; unitBomCost: number;
totalBomCost: number; totalBomCost: number;
totalFeePercent: number; platformFee: number;
estPlatformFee: number; effectiveFeePercent: number;
feeSource: 'actual' | 'hybrid' | 'fallback_30pct';
netRevenue: number; netRevenue: number;
netProfit: number; netProfit: number;
netMarginPercent: number; netMarginPercent: number;
@@ -62,6 +65,7 @@ interface OmnichannelItem {
interface ChannelStat { interface ChannelStat {
soldQty: number; soldQty: number;
revenue: number; revenue: number;
platformFee: number;
bomCost: number; bomCost: number;
netProfit: number; netProfit: number;
} }
@@ -78,10 +82,16 @@ interface OmnichannelKpis {
totalMappedProducts: number; totalMappedProducts: number;
totalUnitsSold: number; totalUnitsSold: number;
totalGrossRevenue: number; totalGrossRevenue: number;
totalPlatformFee: number;
totalEstNetRevenue: number; totalEstNetRevenue: number;
totalBOMCost: number; totalBOMCost: number;
totalEstNetProfit: number; totalEstNetProfit: number;
overallNetMargin: number; overallNetMargin: number;
effectiveFeeRate: number;
actualFeeAmount: number;
fallbackFeeAmount: number;
actualOrdersCount: number;
fallbackOrdersCount: number;
channelStats: Record<string, ChannelStat>; channelStats: Record<string, ChannelStat>;
} }
@@ -89,6 +99,7 @@ export default function OmnichannelMarginPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [data, setData] = useState<{ const [data, setData] = useState<{
periodInfo: PeriodInfo; periodInfo: PeriodInfo;
feePolicy?: { mode: string; fallbackPercent: number; description: string };
kpis: OmnichannelKpis; kpis: OmnichannelKpis;
items: OmnichannelItem[]; items: OmnichannelItem[];
} | null>(null); } | null>(null);
@@ -163,9 +174,11 @@ export default function OmnichannelMarginPage() {
'Units Sold', 'Units Sold',
'Avg Price', 'Avg Price',
'Total Revenue', 'Total Revenue',
'Platform Fee (THB)',
'Effective Fee %',
'Fee Source',
'Unit BOM Cost', 'Unit BOM Cost',
'Total BOM Cost', 'Total BOM Cost',
'Platform Fee %',
'Net Revenue', 'Net Revenue',
'Net Profit', 'Net Profit',
'Net Margin %', 'Net Margin %',
@@ -184,9 +197,11 @@ export default function OmnichannelMarginPage() {
i.totalSoldQty, i.totalSoldQty,
i.avgSellingPrice, i.avgSellingPrice,
i.totalRevenue, i.totalRevenue,
i.platformFee,
`${i.effectiveFeePercent}%`,
i.feeSource,
i.unitBomCost, i.unitBomCost,
i.totalBomCost, i.totalBomCost,
`${i.totalFeePercent}%`,
i.netRevenue, i.netRevenue,
i.netProfit, i.netProfit,
`${i.netMarginPercent}%`, `${i.netMarginPercent}%`,
@@ -288,19 +303,58 @@ export default function OmnichannelMarginPage() {
</div> </div>
</div> </div>
{/* ── Executive KPI Cards ────────────────────────────────────────── */} {/* ── Fee Policy Notice Banner ─────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="flex flex-wrap items-center justify-between gap-3 p-4 rounded-2xl bg-slate-900/90 border border-white/10 shadow-lg backdrop-blur-sm text-xs text-slate-300">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400">
<ShieldCheck className="w-5 h-5 shrink-0" />
</div>
<div>
<span className="font-bold text-white">Hybrid Fee Policy (Actual + 30% Fallback):</span>{' '}
<span className="text-slate-400">
Shopee ดึงค่าธรรมเนียมจริงจากรายงานคำสั่งซื้อ, TikTok คำนวณจากยอดโอนจริง (Settlement)
สำหรับออเดอร์ที่ยังไม่มีข้อมูลค่าธรรมเนียม จะใช้ตัวคูณปลอดภัย{' '}
<strong className="text-amber-400">30% Fallback</strong>
</span>
</div>
</div>
<div className="flex items-center gap-2 text-[11px] font-mono shrink-0">
<span className="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-bold">
Actual: {fmtNum(kpis?.actualOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.actualFeeAmount)})
</span>
<span className="px-2.5 py-1 rounded-lg bg-amber-500/15 text-amber-300 border border-amber-500/30 font-bold">
30% Fallback: {fmtNum(kpis?.fallbackOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.fallbackFeeAmount)})
</span>
</div>
</div>
{/* ── Executive KPI Cards (5 Cards) ──────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Total Gross Revenue */} {/* Total Gross Revenue */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm"> <div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Gross Revenue ({periodInfo?.label || 'All'})</span> <span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Gross Revenue</span>
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400"> <div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
<Coins className="w-5 h-5" /> <Coins className="w-5 h-5" />
</div> </div>
</div> </div>
<div className="text-2xl font-extrabold text-white">{fmtMoney(kpis?.totalGrossRevenue)}</div> <div className="text-2xl font-extrabold text-white">{fmtMoney(kpis?.totalGrossRevenue)}</div>
<p className="text-[11px] text-slate-400 mt-2 font-mono"> <p className="text-[11px] text-slate-400 mt-2 font-mono">
{fmtNum(kpis?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} รายการ SKU) {fmtNum(kpis?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} SKU)
</p>
</div>
{/* Total Platform Fee */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Platform Deductions</span>
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-400">
<Receipt className="w-5 h-5" />
</div>
</div>
<div className="text-2xl font-extrabold text-amber-400">{fmtMoney(kpis?.totalPlatformFee)}</div>
<p className="text-[11px] text-slate-400 mt-2 font-mono">
เฉลี่ยหัก <strong className="text-amber-300 font-bold">~{kpis?.effectiveFeeRate ?? 30}%</strong> ของยอดขาย
</p> </p>
</div> </div>
@@ -314,28 +368,28 @@ export default function OmnichannelMarginPage() {
</div> </div>
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalBOMCost)}</div> <div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalBOMCost)}</div>
<p className="text-[11px] text-slate-400 mt-2"> <p className="text-[11px] text-slate-400 mt-2">
ต้นทุนวัตถุดิบจริงสะสมของสินค้าที่ขายได้ในช่วงเวลานี้ ต้นทุนวัตถุดิบจริงสะสมของสินค้า
</p> </p>
</div> </div>
{/* Total Est Net Profit */} {/* Total Est Net Profit */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm"> <div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Est. Net Profit (หลังหัก Fee)</span> <span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Est. Net Profit</span>
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400"> <div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
<TrendingUp className="w-5 h-5" /> <TrendingUp className="w-5 h-5" />
</div> </div>
</div> </div>
<div className="text-2xl font-extrabold text-emerald-400">{fmtMoney(kpis?.totalEstNetProfit)}</div> <div className="text-2xl font-extrabold text-emerald-400">{fmtMoney(kpis?.totalEstNetProfit)}</div>
<p className="text-[11px] text-slate-400 mt-2"> <p className="text-[11px] text-slate-400 mt-2">
หักทั้งค่าธรรมเนียม Platform และต้นทุน BOM หักค่า Platform และต้นทุน BOM แล้ว
</p> </p>
</div> </div>
{/* Overall Net Margin % */} {/* Overall Net Margin % */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm"> <div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Net Margin % ({periodInfo?.label || 'All'})</span> <span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Net Margin %</span>
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400"> <div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
<Percent className="w-5 h-5" /> <Percent className="w-5 h-5" />
</div> </div>
@@ -380,6 +434,12 @@ export default function OmnichannelMarginPage() {
</span> </span>
</div> </div>
<div className="flex items-baseline justify-between mt-1"> <div className="flex items-baseline justify-between mt-1">
<span className="text-xs text-slate-400">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.shopee?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<span className="text-xs text-slate-400">Net Profit:</span> <span className="text-xs text-slate-400">Net Profit:</span>
<span className="text-base font-bold text-emerald-400 font-mono"> <span className="text-base font-bold text-emerald-400 font-mono">
{fmtMoney(kpis.channelStats.shopee?.netProfit)} {fmtMoney(kpis.channelStats.shopee?.netProfit)}
@@ -414,6 +474,12 @@ export default function OmnichannelMarginPage() {
</span> </span>
</div> </div>
<div className="flex items-baseline justify-between mt-1"> <div className="flex items-baseline justify-between mt-1">
<span className="text-xs text-slate-400">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.lazada?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<span className="text-xs text-slate-400">Net Profit:</span> <span className="text-xs text-slate-400">Net Profit:</span>
<span className="text-base font-bold text-emerald-400 font-mono"> <span className="text-base font-bold text-emerald-400 font-mono">
{fmtMoney(kpis.channelStats.lazada?.netProfit)} {fmtMoney(kpis.channelStats.lazada?.netProfit)}
@@ -448,6 +514,12 @@ export default function OmnichannelMarginPage() {
</span> </span>
</div> </div>
<div className="flex items-baseline justify-between mt-1"> <div className="flex items-baseline justify-between mt-1">
<span className="text-xs text-slate-400">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.tiktok?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<span className="text-xs text-slate-400">Net Profit:</span> <span className="text-xs text-slate-400">Net Profit:</span>
<span className="text-base font-bold text-emerald-400 font-mono"> <span className="text-base font-bold text-emerald-400 font-mono">
{fmtMoney(kpis.channelStats.tiktok?.netProfit)} {fmtMoney(kpis.channelStats.tiktok?.netProfit)}
@@ -469,7 +541,7 @@ export default function OmnichannelMarginPage() {
<th className="py-4 px-4 text-center">ขายได้ ({periodInfo?.label || 'All'})</th> <th className="py-4 px-4 text-center">ขายได้ ({periodInfo?.label || 'All'})</th>
<th className="py-4 px-4 text-right">ราคาขายเฉลี่ย</th> <th className="py-4 px-4 text-right">ราคาขายเฉลี่ย</th>
<th className="py-4 px-4 text-right">ต้นทุน BOM (Unit)</th> <th className="py-4 px-4 text-right">ต้นทุน BOM (Unit)</th>
<th className="py-4 px-4 text-right">หัก Fee (%)</th> <th className="py-4 px-4 text-right">หัก Fee (%) &amp; ที่มา</th>
<th className="py-4 px-4 text-right">Net Rev / Unit</th> <th className="py-4 px-4 text-right">Net Rev / Unit</th>
<th className="py-4 px-4 text-right bg-white/5">Net Profit / Unit</th> <th className="py-4 px-4 text-right bg-white/5">Net Profit / Unit</th>
<th className="py-4 px-4 text-right">Net Margin %</th> <th className="py-4 px-4 text-right">Net Margin %</th>
@@ -528,8 +600,25 @@ export default function OmnichannelMarginPage() {
<td className="py-3.5 px-4 text-right font-mono text-rose-300"> <td className="py-3.5 px-4 text-right font-mono text-rose-300">
{fmtMoney(item.unitBomCost)} {fmtMoney(item.unitBomCost)}
</td> </td>
<td className="py-3.5 px-4 text-right font-mono text-slate-400"> <td className="py-3.5 px-4 text-right">
~{item.totalFeePercent}% <div className="font-mono text-slate-200">
~{item.effectiveFeePercent}%
</div>
<div className="text-[10px] mt-0.5">
{item.feeSource === 'actual' ? (
<span className="px-1.5 py-0.5 rounded font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
Actual
</span>
) : item.feeSource === 'hybrid' ? (
<span className="px-1.5 py-0.5 rounded font-bold bg-indigo-500/20 text-indigo-400 border border-indigo-500/30">
Hybrid
</span>
) : (
<span className="px-1.5 py-0.5 rounded font-medium bg-amber-500/15 text-amber-300 border border-amber-500/30">
30% Fallback
</span>
)}
</div>
</td> </td>
<td className="py-3.5 px-4 text-right font-mono text-slate-200"> <td className="py-3.5 px-4 text-right font-mono text-slate-200">
{fmtMoney(unitNetRevenue)} {fmtMoney(unitNetRevenue)}
+171 -39
View File
@@ -60,6 +60,15 @@ function parseOrderDate(dateStr: string | Date | null | undefined): Date | null
return isNaN(d.getTime()) ? null : d; 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" }) export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -313,20 +322,6 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
orderBy: { totalRevenue: "desc" }, 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 // Look up helper: map marketplace item keys to marketplaceProduct.id
const mpLookup = new Map<string, number>(); const mpLookup = new Map<string, number>();
for (const mp of mpProducts) { for (const mp of mpProducts) {
@@ -337,13 +332,33 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
} }
} }
// A. TikTok Orders in Range // 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") { if (channel === "all" || channel === "tiktok") {
const ttOrders = await prisma.tiktokOrder.findMany({ const ttOrders = await prisma.tiktokOrder.findMany({
where: { createdTime: { not: null } }, where: { createdTime: { not: null } },
select: { select: {
orderId: true,
createdTime: true, createdTime: true,
shop: true, shop: true,
orderAmount: true,
commissionFee: true,
affiliateFee: true,
netSettlement: true,
isSettled: true,
items: { items: {
select: { select: {
sellerSku: true, sellerSku: true,
@@ -360,7 +375,15 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
for (const order of ttOrders) { for (const order of ttOrders) {
const orderDate = parseOrderDate(order.createdTime); const orderDate = parseOrderDate(order.createdTime);
if (!orderDate || orderDate < start || orderDate > end) continue; if (start && (!orderDate || orderDate < start || orderDate > end)) 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;
for (const item of order.items) { for (const item of order.items) {
const sku = item.sellerSku || item.skuId || ""; const sku = item.sellerSku || item.skuId || "";
@@ -371,21 +394,42 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
const qty = item.quantity || 1; const qty = item.quantity || 1;
const rev = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty); const rev = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
const cur = dynamicSalesMap.get(mpId) || {
soldQty: 0,
revenue: 0,
actualFeeSum: 0,
fallbackFeeSum: 0,
actualCount: 0,
fallbackCount: 0,
};
cur.soldQty += qty; cur.soldQty += qty;
cur.revenue += rev; 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); dynamicSalesMap.set(mpId, cur);
} }
} }
} }
// B. Shopee Orders in Range // B. Shopee Orders
if (channel === "all" || channel === "shopee") { if (channel === "all" || channel === "shopee") {
const spOrders = await prisma.shopeeOrder.findMany({ const spOrders = await prisma.shopeeOrder.findMany({
where: { orderCreatedAt: { not: null } }, where: { orderCreatedAt: { not: null } },
select: { select: {
orderId: true,
orderCreatedAt: true, orderCreatedAt: true,
shop: true, shop: true,
totalAmount: true,
items: { items: {
select: { select: {
parentSku: true, parentSku: true,
@@ -395,6 +439,7 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
quantity: true, quantity: true,
totalPayment: true, totalPayment: true,
dealPrice: true, dealPrice: true,
rawData: true,
}, },
}, },
}, },
@@ -402,7 +447,17 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
for (const order of spOrders) { for (const order of spOrders) {
const orderDate = parseOrderDate(order.orderCreatedAt); const orderDate = parseOrderDate(order.orderCreatedAt);
if (!orderDate || orderDate < start || orderDate > end) continue; if (start && (!orderDate || orderDate < start || orderDate > end)) 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);
for (const item of order.items) { for (const item of order.items) {
const sku = item.skuRef || item.parentSku || ""; const sku = item.skuRef || item.parentSku || "";
@@ -413,15 +468,33 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
const qty = item.quantity || 1; const qty = item.quantity || 1;
const rev = item.totalPayment > 0 ? item.totalPayment : (item.dealPrice * qty); const rev = item.totalPayment > 0 ? item.totalPayment : (item.dealPrice * qty);
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
const cur = dynamicSalesMap.get(mpId) || {
soldQty: 0,
revenue: 0,
actualFeeSum: 0,
fallbackFeeSum: 0,
actualCount: 0,
fallbackCount: 0,
};
cur.soldQty += qty; cur.soldQty += qty;
cur.revenue += rev; 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); dynamicSalesMap.set(mpId, cur);
} }
} }
} }
// C. Lazada Orders in Range // C. Lazada Orders
if (channel === "all" || channel === "lazada") { if (channel === "all" || channel === "lazada") {
const lzOrders = await prisma.lazadaOrder.findMany({ const lzOrders = await prisma.lazadaOrder.findMany({
where: { createTime: { not: null } }, where: { createTime: { not: null } },
@@ -443,7 +516,7 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
for (const order of lzOrders) { for (const order of lzOrders) {
const orderDate = parseOrderDate(order.createTime); const orderDate = parseOrderDate(order.createTime);
if (!orderDate || orderDate < start || orderDate > end) continue; if (start && (!orderDate || orderDate < start || orderDate > end)) continue;
for (const item of order.items) { for (const item of order.items) {
const sku = item.sellerSku || item.lazadaSku || ""; const sku = item.sellerSku || item.lazadaSku || "";
@@ -454,14 +527,24 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
const qty = 1; const qty = 1;
const rev = item.paidPrice > 0 ? item.paidPrice : item.unitPrice; const rev = item.paidPrice > 0 ? item.paidPrice : item.unitPrice;
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
const cur = dynamicSalesMap.get(mpId) || {
soldQty: 0,
revenue: 0,
actualFeeSum: 0,
fallbackFeeSum: 0,
actualCount: 0,
fallbackCount: 0,
};
cur.soldQty += qty; cur.soldQty += qty;
cur.revenue += rev; 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); dynamicSalesMap.set(mpId, cur);
} }
} }
} }
}
// 3. Map Items & Accumulate KPIs // 3. Map Items & Accumulate KPIs
let totalGrossRevenue = 0; let totalGrossRevenue = 0;
@@ -469,25 +552,58 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
let totalBOMCost = 0; let totalBOMCost = 0;
let totalEstNetProfit = 0; let totalEstNetProfit = 0;
let totalUnitsSold = 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 }> = { const channelStats: Record<string, { soldQty: number; revenue: number; platformFee: number; bomCost: number; netProfit: number }> = {
shopee: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, shopee: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
lazada: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, lazada: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
tiktok: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, tiktok: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
}; };
const mappedItems: any[] = []; const mappedItems: any[] = [];
for (const item of mpProducts) { for (const item of mpProducts) {
let soldQty = item.totalSoldQty; let soldQty = 0;
let rev = item.totalRevenue; 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); const dyn = dynamicSalesMap.get(item.id);
if (!dyn || dyn.soldQty === 0) continue; // Skip items with no sales in this date range
if (dyn && dyn.soldQty > 0) {
soldQty = dyn.soldQty; soldQty = dyn.soldQty;
rev = dyn.revenue; 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!; const v = item.masterVariant!;
@@ -498,13 +614,11 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
const itemBomCost = Math.round(unitBomCost * soldQty * 100) / 100; const itemBomCost = Math.round(unitBomCost * soldQty * 100) / 100;
const ch = item.channel.toLowerCase(); 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 - platformFee) * 100) / 100;
const netRevenue = Math.round((rev - estPlatformFee) * 100) / 100;
const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100; const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100;
const netMarginPercent = rev > 0 ? Math.round((netProfit / rev) * 1000) / 10 : 0; 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 avgSellingPrice = soldQty > 0 ? Math.round((rev / soldQty) * 100) / 100 : item.avgPrice;
const directPrice = v.price || avgSellingPrice || 0; 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; const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0;
totalGrossRevenue += rev; totalGrossRevenue += rev;
totalPlatformFee += platformFee;
totalEstNetRevenue += netRevenue; totalEstNetRevenue += netRevenue;
totalBOMCost += itemBomCost; totalBOMCost += itemBomCost;
totalEstNetProfit += netProfit; totalEstNetProfit += netProfit;
totalUnitsSold += soldQty; totalUnitsSold += soldQty;
totalActualFeeSum += actualFeePart;
totalFallbackFeeSum += fallbackFeePart;
if (channelStats[ch]) { if (channelStats[ch]) {
channelStats[ch].soldQty += soldQty; channelStats[ch].soldQty += soldQty;
channelStats[ch].revenue += rev; channelStats[ch].revenue += rev;
channelStats[ch].platformFee += platformFee;
channelStats[ch].bomCost += itemBomCost; channelStats[ch].bomCost += itemBomCost;
channelStats[ch].netProfit += netProfit; channelStats[ch].netProfit += netProfit;
} }
@@ -540,8 +658,9 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
categoryName: v.product?.category?.name || "Uncategorized", categoryName: v.product?.category?.name || "Uncategorized",
unitBomCost: Math.round(unitBomCost * 100) / 100, unitBomCost: Math.round(unitBomCost * 100) / 100,
totalBomCost: itemBomCost, totalBomCost: itemBomCost,
totalFeePercent, platformFee,
estPlatformFee, effectiveFeePercent,
feeSource, // "actual" | "hybrid" | "fallback_30pct"
netRevenue, netRevenue,
netProfit, netProfit,
netMarginPercent, netMarginPercent,
@@ -563,6 +682,8 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
const overallNetMargin = const overallNetMargin =
totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0; totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0;
const effectiveFeeRate =
totalGrossRevenue > 0 ? Math.round((totalPlatformFee / totalGrossRevenue) * 1000) / 10 : DEFAULT_FALLBACK_FEE_PERCENT;
return { return {
periodInfo: { periodInfo: {
@@ -572,14 +693,25 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null, endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null,
daysCount: dateRange.daysCount, 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: { kpis: {
totalMappedProducts: mappedItems.length, totalMappedProducts: mappedItems.length,
totalUnitsSold, totalUnitsSold,
totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100, totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100,
totalPlatformFee: Math.round(totalPlatformFee * 100) / 100,
totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100, totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100,
totalBOMCost: Math.round(totalBOMCost * 100) / 100, totalBOMCost: Math.round(totalBOMCost * 100) / 100,
totalEstNetProfit: Math.round(totalEstNetProfit * 100) / 100, totalEstNetProfit: Math.round(totalEstNetProfit * 100) / 100,
overallNetMargin, overallNetMargin,
effectiveFeeRate,
actualFeeAmount: Math.round(totalActualFeeSum * 100) / 100,
fallbackFeeAmount: Math.round(totalFallbackFeeSum * 100) / 100,
actualOrdersCount: totalActualOrdersCount,
fallbackOrdersCount: totalFallbackOrdersCount,
channelStats, channelStats,
}, },
items: filtered, items: filtered,