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
@@ -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 (%) & ที่มา</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)}
|
||||||
|
|||||||
@@ -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,152 +322,226 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
|||||||
orderBy: { totalRevenue: "desc" },
|
orderBy: { totalRevenue: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const CHANNEL_FEES: Record<string, { commissionPercent: number; servicePercent: number; paymentPercent: number }> = {
|
// Look up helper: map marketplace item keys to marketplaceProduct.id
|
||||||
shopee: { commissionPercent: 8.0, servicePercent: 5.0, paymentPercent: 3.21 }, // ~16.21%
|
const mpLookup = new Map<string, number>();
|
||||||
lazada: { commissionPercent: 6.0, servicePercent: 4.0, paymentPercent: 3.21 }, // ~13.21%
|
for (const mp of mpProducts) {
|
||||||
tiktok: { commissionPercent: 6.0, servicePercent: 4.5, paymentPercent: 3.0 }, // ~13.5%
|
const key = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`;
|
||||||
};
|
mpLookup.set(key, mp.id);
|
||||||
|
if (mp.marketplaceSku) {
|
||||||
// 2. If a specific Date Range is selected, calculate dynamic sales from Orders
|
mpLookup.set(`${mp.channel}:::${mp.marketplaceSku}`, mp.id);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A. TikTok Orders in Range
|
// 2. Dynamic Order Aggregator (Actual Fee Extraction with 30% Fallback)
|
||||||
if (channel === "all" || channel === "tiktok") {
|
interface DynamicSalesEntry {
|
||||||
const ttOrders = await prisma.tiktokOrder.findMany({
|
soldQty: number;
|
||||||
where: { createdTime: { not: null } },
|
revenue: number;
|
||||||
select: {
|
actualFeeSum: number;
|
||||||
createdTime: true,
|
fallbackFeeSum: number;
|
||||||
shop: true,
|
actualCount: number;
|
||||||
items: {
|
fallbackCount: number;
|
||||||
select: {
|
}
|
||||||
sellerSku: true,
|
|
||||||
skuId: true,
|
const dynamicSalesMap = new Map<number, DynamicSalesEntry>();
|
||||||
productName: true,
|
const start = dateRange.start;
|
||||||
variation: true,
|
const end = dateRange.end || new Date();
|
||||||
quantity: true,
|
|
||||||
subtotalAfterDiscount: true,
|
// A. TikTok Orders
|
||||||
unitOriginalPrice: true,
|
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) {
|
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;
|
||||||
|
|
||||||
for (const item of order.items) {
|
const orderAmount = order.orderAmount || 0;
|
||||||
const sku = item.sellerSku || item.skuId || "";
|
// Check actual deduction from settlement or commission
|
||||||
const key1 = `tiktok:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variation || ""}:::${sku}`;
|
const hasSettlement = order.isSettled && order.netSettlement > 0;
|
||||||
const key2 = `tiktok:::${sku}`;
|
const actualOrderFee = hasSettlement
|
||||||
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
? Math.max(0, orderAmount - order.netSettlement)
|
||||||
if (!mpId) continue;
|
: ((order.commissionFee || 0) + (order.affiliateFee || 0));
|
||||||
|
const isActual = actualOrderFee > 0;
|
||||||
|
|
||||||
const qty = item.quantity || 1;
|
for (const item of order.items) {
|
||||||
const rev = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
|
const sku = item.sellerSku || item.skuId || "";
|
||||||
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
|
const key1 = `tiktok:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variation || ""}:::${sku}`;
|
||||||
cur.soldQty += qty;
|
const key2 = `tiktok:::${sku}`;
|
||||||
cur.revenue += rev;
|
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
||||||
dynamicSalesMap.set(mpId, cur);
|
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
|
// 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: {
|
||||||
orderCreatedAt: true,
|
orderId: true,
|
||||||
shop: true,
|
orderCreatedAt: true,
|
||||||
items: {
|
shop: true,
|
||||||
select: {
|
totalAmount: true,
|
||||||
parentSku: true,
|
items: {
|
||||||
skuRef: true,
|
select: {
|
||||||
productName: true,
|
parentSku: true,
|
||||||
variantName: true,
|
skuRef: true,
|
||||||
quantity: true,
|
productName: true,
|
||||||
totalPayment: true,
|
variantName: true,
|
||||||
dealPrice: true,
|
quantity: true,
|
||||||
},
|
totalPayment: true,
|
||||||
|
dealPrice: true,
|
||||||
|
rawData: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
for (const item of order.items) {
|
// Shopee Order-level actual fee from rawData (first item has order-level fees)
|
||||||
const sku = item.skuRef || item.parentSku || "";
|
const firstRaw = (order.items[0]?.rawData as any) || {};
|
||||||
const key1 = `shopee:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variantName || ""}:::${sku}`;
|
const comm = toNum(firstRaw["ค่าคอมมิชชั่น"]);
|
||||||
const key2 = `shopee:::${sku}`;
|
const service = toNum(firstRaw["ค่าบริการ"]);
|
||||||
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
const tx = toNum(firstRaw["Transaction Fee"]);
|
||||||
if (!mpId) continue;
|
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;
|
for (const item of order.items) {
|
||||||
const rev = item.totalPayment > 0 ? item.totalPayment : (item.dealPrice * qty);
|
const sku = item.skuRef || item.parentSku || "";
|
||||||
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
|
const key1 = `shopee:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variantName || ""}:::${sku}`;
|
||||||
cur.soldQty += qty;
|
const key2 = `shopee:::${sku}`;
|
||||||
cur.revenue += rev;
|
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
||||||
dynamicSalesMap.set(mpId, cur);
|
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
|
// 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 } },
|
||||||
select: {
|
select: {
|
||||||
createTime: true,
|
createTime: true,
|
||||||
shop: true,
|
shop: true,
|
||||||
items: {
|
items: {
|
||||||
select: {
|
select: {
|
||||||
sellerSku: true,
|
sellerSku: true,
|
||||||
lazadaSku: true,
|
lazadaSku: true,
|
||||||
itemName: true,
|
itemName: true,
|
||||||
variation: true,
|
variation: true,
|
||||||
paidPrice: true,
|
paidPrice: true,
|
||||||
unitPrice: true,
|
unitPrice: true,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
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 || "";
|
||||||
const key1 = `lazada:::${order.shop || "hom-aroma"}:::${item.itemName}:::${item.variation || ""}:::${sku}`;
|
const key1 = `lazada:::${order.shop || "hom-aroma"}:::${item.itemName}:::${item.variation || ""}:::${sku}`;
|
||||||
const key2 = `lazada:::${sku}`;
|
const key2 = `lazada:::${sku}`;
|
||||||
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
||||||
if (!mpId) continue;
|
if (!mpId) continue;
|
||||||
|
|
||||||
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 };
|
|
||||||
cur.soldQty += qty;
|
const cur = dynamicSalesMap.get(mpId) || {
|
||||||
cur.revenue += rev;
|
soldQty: 0,
|
||||||
dynamicSalesMap.set(mpId, cur);
|
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 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
|
const dyn = dynamicSalesMap.get(item.id);
|
||||||
if (dynamicSalesMap) {
|
|
||||||
const dyn = dynamicSalesMap.get(item.id);
|
if (dyn && dyn.soldQty > 0) {
|
||||||
if (!dyn || dyn.soldQty === 0) continue; // Skip items with no sales in this date range
|
|
||||||
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,
|
||||||
|
|||||||
Reference in new issue
Block a user