This commit is contained in:
Your Name committed 2026-09-06 14:39:13 +07:00
1 parent abee5249ca
commit 2c8da0ae2a
24 files changed
+9413 -414

No files matched your search

+563 -47
View File
@@ -7,7 +7,7 @@ const toNum = (val: any): number => {
const s = String(val).replace(/[^0-9.\-]/g, "");
const n = parseFloat(s);
return isNaN(n) ? 0 : n;
};
};
// TikTok CSV values can have trailing \t — trim everything
const str = (val: any): string => String(val ?? "").replace(/\t/g, "").trim();
@@ -26,9 +26,216 @@ const parseTiktokDate = (val: any): string => {
return d;
};
// Helper to enrich TikTok orders with BOM & Profitability
async function enrichOrdersWithBOM(orders: any[], shop: string) {
if (!orders || orders.length === 0) return [];
const orderIds = orders.map((o) => o.orderId).filter(Boolean);
// Fetch all MarketplaceProduct mappings, master variants, and settlements for this batch
const [marketplaceProducts, masterVariants, settlements] = await Promise.all([
prisma.marketplaceProduct.findMany({
where: { channel: "tiktok", shop },
include: {
masterVariant: {
include: {
product: { select: { id: true, name: true } },
bomItems: {
include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } } },
},
},
},
},
}),
prisma.productVariant.findMany({
include: {
product: { select: { id: true, name: true } },
bomItems: {
include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } } },
},
},
}),
orderIds.length > 0
? prisma.tiktokSettlement.findMany({ where: { orderId: { in: orderIds } } })
: Promise.resolve([]),
]);
// Index Settlements
const settlementMap = new Map<string, any>();
for (const s of settlements) {
settlementMap.set(s.orderId, s);
}
// Index MarketplaceProducts
const mpExactMap = new Map<string, any>(); // shop:::productName:::variation:::sku
const mpSkuMap = new Map<string, any>(); // shop:::sku
const mpNameVarMap = new Map<string, any>(); // shop:::productName:::variation
const variantSkuMap = new Map<string, any>(); // SKU uppercase -> ProductVariant
for (const mp of marketplaceProducts) {
const s = (mp.shop || "hom-aroma").trim().toLowerCase();
const pName = (mp.productName || "").trim().toLowerCase();
const vName = (mp.variation || "").trim().toLowerCase();
const sku = (mp.marketplaceSku || "").trim().toLowerCase();
const exactKey = `${s}:::${pName}:::${vName}:::${sku}`;
mpExactMap.set(exactKey, mp);
if (sku) {
mpSkuMap.set(`${s}:::${sku}`, mp);
}
if (pName) {
mpNameVarMap.set(`${s}:::${pName}:::${vName}`, mp);
}
}
for (const v of masterVariants) {
if (v.sku) {
variantSkuMap.set(v.sku.trim().toLowerCase(), v);
}
}
return orders.map((order) => {
const oShop = (order.shop || "hom-aroma").trim().toLowerCase();
let orderTotalBomCost = 0;
let missingBomCount = 0;
const enrichedItems = (order.items || []).map((item: any) => {
const pName = (item.productName || "").trim().toLowerCase();
const vName = (item.variation || "").trim().toLowerCase();
const sSku = (item.sellerSku || item.skuId || "").trim().toLowerCase();
// Resolve Master Variant
let resolvedVariant: any = null;
let matchedVia = "";
// 1. Exact Match
const exact = mpExactMap.get(`${oShop}:::${pName}:::${vName}:::${sSku}`);
if (exact?.masterVariant) {
resolvedVariant = exact.masterVariant;
matchedVia = "marketplace_exact";
}
// 2. SKU Match in MarketplaceProduct
if (!resolvedVariant && sSku) {
const bySku = mpSkuMap.get(`${oShop}:::${sSku}`);
if (bySku?.masterVariant) {
resolvedVariant = bySku.masterVariant;
matchedVia = "marketplace_sku";
}
}
// 3. Name + Variation Match in MarketplaceProduct
if (!resolvedVariant && pName) {
const byNameVar = mpNameVarMap.get(`${oShop}:::${pName}:::${vName}`);
if (byNameVar?.masterVariant) {
resolvedVariant = byNameVar.masterVariant;
matchedVia = "marketplace_name_variation";
}
}
// 4. Direct Core Variant SKU match
if (!resolvedVariant && sSku) {
const direct = variantSkuMap.get(sSku);
if (direct) {
resolvedVariant = direct;
matchedVia = "direct_sku";
}
}
// Calculate BOM Cost
let unitBomCost = 0;
let bomItemsDetail: any[] = [];
let hasBom = false;
let hasMapping = !!resolvedVariant;
if (resolvedVariant) {
const bItems = resolvedVariant.bomItems || [];
unitBomCost = bItems.reduce((acc: number, b: any) => {
return acc + (Number(b.quantity) || 0) * (Number(b.rawMaterial?.pricePerUnit) || 0);
}, 0);
bomItemsDetail = bItems.map((b: any) => ({
rawMaterialId: b.rawMaterialId,
name: b.rawMaterial?.name || "วัตถุดิบ",
unit: b.rawMaterial?.unit || "หน่วย",
pricePerUnit: b.rawMaterial?.pricePerUnit || 0,
quantity: b.quantity || 0,
lineCost: (Number(b.quantity) || 0) * (Number(b.rawMaterial?.pricePerUnit) || 0),
}));
hasBom = unitBomCost > 0 && bItems.length > 0;
}
const qty = Number(item.quantity) || 1;
const totalBomCost = unitBomCost * qty;
const itemRevenue = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
const profit = itemRevenue - totalBomCost;
const marginPercent = itemRevenue > 0 ? (profit / itemRevenue) * 100 : 0;
if (!hasBom) {
missingBomCount++;
}
orderTotalBomCost += totalBomCost;
return {
...item,
unitBomCost,
totalBomCost,
profit,
marginPercent,
hasBom,
hasMapping,
matchedVia,
masterProductId: resolvedVariant?.productId || (typeof resolvedVariant?.product?.id === "number" ? resolvedVariant.product.id : null),
masterProductName: typeof resolvedVariant?.product?.name === "string" ? resolvedVariant.product.name : (resolvedVariant?.product?.name as any)?.th || (resolvedVariant?.product?.name as any)?.en || null,
masterSku: resolvedVariant?.sku || null,
masterVariantId: resolvedVariant?.id || null,
bomItemsDetail,
};
});
const settlement = settlementMap.get(order.orderId);
const rawComm = (settlement?.commissionFee ?? order.commissionFee ?? 0);
const commissionFee = Math.abs(rawComm);
const rawAff = settlement
? (settlement.affiliateCommission + settlement.affiliatePartnerFee)
: (order.affiliateFee ?? 0);
const affiliateFee = Math.abs(rawAff);
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (order.orderAmount - commissionFee - affiliateFee);
const isSettled = order.isSettled || !!settlement;
const settledAt = order.settledAt || settlement?.orderSettledTime || null;
const orderEstProfit = order.orderAmount - orderTotalBomCost;
const orderNetProfit = (isSettled && settlement ? settlement.settlementAmount : (order.orderAmount - commissionFee - affiliateFee)) - orderTotalBomCost;
const orderGrossMargin = order.orderAmount > 0 ? (orderEstProfit / order.orderAmount) * 100 : 0;
const allBomComplete = missingBomCount === 0 && enrichedItems.length > 0;
const hasIncompleteBom = missingBomCount > 0;
return {
...order,
items: enrichedItems,
settlement: settlement || null,
orderTotalBomCost,
orderEstProfit,
orderNetProfit,
commissionFee,
affiliateFee,
netSettlement,
isSettled,
settledAt,
orderGrossMargin,
missingBomCount,
allBomComplete,
hasIncompleteBom,
};
});
}
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
// ── Import (CSV rows as JSON) ──────────────────────────────────────────────
// ── Import Orders (CSV rows as JSON) ───────────────────────────────────────
.post(
"/import",
async ({ body, set }) => {
@@ -57,6 +264,8 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
const chunk = entries.slice(i, i + CHUNK);
await prisma.$transaction(async (tx) => {
for (const [orderId, { meta, items }] of chunk) {
const existingSettlement = await tx.tiktokSettlement.findUnique({ where: { orderId } });
// Delete old items first (sync strategy)
await tx.tiktokOrderItem.deleteMany({ where: { orderId } });
@@ -104,6 +313,11 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
shippedTime: parseTiktokDate(meta["Shipped Time"]),
deliveredTime: parseTiktokDate(meta["Delivered Time"]),
cancelledTime: parseTiktokDate(meta["Cancelled Time"]),
commissionFee: existingSettlement ? existingSettlement.commissionFee : 0,
affiliateFee: existingSettlement ? (existingSettlement.affiliateCommission + existingSettlement.affiliatePartnerFee) : 0,
netSettlement: existingSettlement ? existingSettlement.settlementAmount : 0,
settledAt: existingSettlement ? existingSettlement.orderSettledTime : null,
isSettled: !!existingSettlement,
};
await tx.tiktokOrder.upsert({
@@ -114,7 +328,6 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
upserted++;
}
}, { timeout: 30000 });
console.log(`[TiktokOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`);
}
console.log(`[TiktokOrder] ✅ Done — ${upserted} orders upserted`);
@@ -128,56 +341,301 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
)
// ── Import Settlement (Income XLSX rows as JSON) ───────────────────────────
.post(
"/import-settlement",
async ({ body, set }) => {
try {
const { rows, shop } = body as { rows: any[]; shop?: string };
const shopName = shop || "hom-aroma";
console.log(`[TiktokSettlement] Received ${rows.length} raw settlement rows for shop: ${shopName}`);
let upsertedCount = 0;
let matchedOrdersCount = 0;
let totalSettlementAmount = 0;
let totalCommission = 0;
let totalAffiliate = 0;
const CHUNK = 50;
for (let i = 0; i < rows.length; i += CHUNK) {
const chunk = rows.slice(i, i + CHUNK);
await prisma.$transaction(async (tx) => {
for (const row of chunk) {
const orderId = str(
row["หมายเลขคำสั่งซื้อ/การปรับ"] ||
row["หมายเลขคำสั่งซื้อ"] ||
row["Order ID"] ||
row["order_id"]
);
if (!orderId) continue;
const transactionType = str(row["ประเภทธุรกรรม"] || row["Transaction Type"]);
const orderCreatedTime = str(row["เวลาที่สร้างคำสั่งซื้อ"] || row["Order Created Time"]);
const orderSettledTime = str(row["เวลาที่ชำระคำสั่งซื้อ"] || row["Order Settled Time"] || row["เวลาที่สำเร็จ"]);
const currency = str(row["สกุลเงิน"] || row["Currency"] || "THB");
const settlementAmount = toNum(row["ยอดการชำระเงินทั้งหมด"] || row["Total Settlement Amount"] || row["จำนวน"]);
const grossRevenue = toNum(row["รายได้ทั้งหมด"] || row["Total Revenue"]);
const subtotalAfterSellerDiscount = toNum(row["ยอดรวมค่าสินค้าหลังหักส่วนลดจากผู้ขาย"]);
const subtotalBeforeDiscount = toNum(row["ยอดรวมค่าสินค้าก่อนหักส่วนลด"]);
const sellerDiscount = toNum(row["ส่วนลดจากร้านค้า"]);
const refundAmount = toNum(row["ยอดรวมเงินคืนหลังหักส่วนลดจากผู้ขาย"]);
const totalFee = toNum(row["ค่าธรรมเนียมทั้งหมด"]);
const orderFee = toNum(row["ค่าธรรมเนียมคำสั่งซื้อ"]);
const commissionFee = Math.abs(toNum(row["ค่าคอมมิชชั่น TikTok Shop"]));
const affiliateCommission = Math.abs(toNum(row["ค่าคอมมิชชั่นแอฟฟิลิเอต"]));
const affiliatePartnerFee = Math.abs(toNum(row["ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต"]));
const growthSupportFee = toNum(row["ค่าธรรมเนียมสนับสนุนการเติบโตของร้านค้า"]);
const shippingFeeActual = toNum(row["ค่าธรรมเนียมการจัดส่งจริง"]);
const platformShippingDiscount = toNum(row["ส่วนลดค่าธรรมเนียมการจัดส่งจากแพลตฟอร์ม"]);
const customerPayment = toNum(row["การชำระเงินของลูกค้า"]);
const totalAff = affiliateCommission + affiliatePartnerFee;
totalSettlementAmount += settlementAmount;
totalCommission += commissionFee;
totalAffiliate += totalAff;
// Upsert into TiktokSettlement
await tx.tiktokSettlement.upsert({
where: { orderId },
update: {
shop: shopName,
transactionType,
orderCreatedTime,
orderSettledTime,
currency,
settlementAmount,
grossRevenue,
subtotalAfterSellerDiscount,
subtotalBeforeDiscount,
sellerDiscount,
refundAmount,
totalFee,
orderFee,
commissionFee,
affiliateCommission,
affiliatePartnerFee,
growthSupportFee,
shippingFeeActual,
platformShippingDiscount,
customerPayment,
rawData: row,
},
create: {
orderId,
shop: shopName,
transactionType,
orderCreatedTime,
orderSettledTime,
currency,
settlementAmount,
grossRevenue,
subtotalAfterSellerDiscount,
subtotalBeforeDiscount,
sellerDiscount,
refundAmount,
totalFee,
orderFee,
commissionFee,
affiliateCommission,
affiliatePartnerFee,
growthSupportFee,
shippingFeeActual,
platformShippingDiscount,
customerPayment,
rawData: row,
},
});
upsertedCount++;
// Update corresponding TiktokOrder if it exists
const matchedOrder = await tx.tiktokOrder.findUnique({ where: { orderId } });
if (matchedOrder) {
await tx.tiktokOrder.update({
where: { orderId },
data: {
commissionFee,
affiliateFee: totalAff,
netSettlement: settlementAmount,
settledAt: orderSettledTime,
isSettled: true,
},
});
matchedOrdersCount++;
}
}
}, { timeout: 30000 });
}
console.log(`[TiktokSettlement] ✅ Done — ${upsertedCount} settlements saved, ${matchedOrdersCount} orders linked`);
return {
success: true,
message: `นำเข้าใบแจ้งยอดสำเร็จ ${upsertedCount} รายการ (เชื่อมโยงคำสั่งซื้อแล้ว ${matchedOrdersCount} ออเดอร์)`,
settlementsCount: upsertedCount,
matchedOrdersCount,
totalSettlementAmount,
totalCommission,
totalAffiliate,
};
} catch (error: any) {
console.error(`[TiktokSettlement] ❌ Error:`, error.message);
set.status = 500;
return { success: false, message: error.message };
}
},
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
)
// ── Clear Shop Data ───────────────────────────────────────────────────────
.delete("/clear", async ({ query, set }) => {
try {
const shop = (query.shop as string) || "hom-aroma";
const result = await prisma.tiktokOrder.deleteMany({ where: { shop } });
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count };
const [orderResult, settlementResult] = await Promise.all([
prisma.tiktokOrder.deleteMany({ where: { shop } }),
prisma.tiktokSettlement.deleteMany({ where: { shop } }),
]);
return {
success: true,
message: `ลบข้อมูลร้าน ${shop} สำเร็จ (คำสั่งซื้อ: ${orderResult.count}, ใบแจ้งยอด: ${settlementResult.count})`,
count: orderResult.count + settlementResult.count,
};
} catch (error: any) {
console.error(`[TiktokOrder] ❌ Clear Error:`, error.message);
set.status = 500;
return { success: false, message: error.message };
}
})
// ── GET Single Order ──────────────────────────────────────────────────────
// ── GET Single Order Detail (with BOM Breakdown & Settlement) ──────────────
.get("/orders/:orderId", async ({ params, set }) => {
const order = await prisma.tiktokOrder.findUnique({ where: { orderId: params.orderId }, include: { items: true } });
if (!order) { set.status = 404; return { error: "Order not found" }; }
return order;
const order = await prisma.tiktokOrder.findUnique({
where: { orderId: params.orderId },
include: { items: true },
});
if (!order) {
set.status = 404;
return { error: "Order not found" };
}
const [enriched] = await enrichOrdersWithBOM([order], order.shop);
return enriched || order;
})
// ── GET Orders (paginated) ────────────────────────────────────────────────
// ── GET Orders (paginated & searchable with BOM & Settlement) ──────────────
.get("/orders", async ({ query }) => {
const page = Number(query.page ?? 1);
const limit = Number(query.limit ?? 30);
const skip = (page - 1) * limit;
const shop = (query.shop as string) || "hom-aroma";
const where = { shop };
const [items, total] = await Promise.all([
prisma.tiktokOrder.findMany({ where, include: { items: true }, orderBy: { createdTime: "desc" }, skip, take: limit }),
const page = Number(query.page ?? 1);
const limit = Number(query.limit ?? 20);
const skip = (page - 1) * limit;
const shop = (query.shop as string) || "hom-aroma";
const search = ((query.search as string) || "").trim();
const status = (query.status as string) || "";
const bomStatus = (query.bomStatus as string) || "all"; // "all" | "missing" | "complete"
const settlementStatus = (query.settlementStatus as string) || "all"; // "all" | "settled" | "pending"
const where: any = { shop };
if (search) {
where.OR = [
{ orderId: { contains: search, mode: "insensitive" as const } },
{ buyerUsername: { contains: search, mode: "insensitive" as const } },
{ trackingId: { contains: search, mode: "insensitive" as const } },
{ province: { contains: search, mode: "insensitive" as const } },
{ district: { contains: search, mode: "insensitive" as const } },
{ items: { some: { productName: { contains: search, mode: "insensitive" as const } } } },
{ items: { some: { sellerSku: { contains: search, mode: "insensitive" as const } } } },
{ items: { some: { variation: { contains: search, mode: "insensitive" as const } } } },
];
}
if (status && status !== "all") {
where.status = { contains: status, mode: "insensitive" as const };
}
if (settlementStatus === "settled") {
where.isSettled = true;
} else if (settlementStatus === "pending") {
where.isSettled = false;
}
const [rawItems, total] = await Promise.all([
prisma.tiktokOrder.findMany({
where,
include: { items: true },
orderBy: { createdTime: "desc" },
skip,
take: limit,
}),
prisma.tiktokOrder.count({ where }),
]);
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
const items = await enrichOrdersWithBOM(rawItems, shop);
let filteredItems = items;
if (bomStatus === "missing") {
filteredItems = items.filter((o) => o.hasIncompleteBom);
} else if (bomStatus === "complete") {
filteredItems = items.filter((o) => o.allBomComplete);
}
return { items: filteredItems, total, page, limit, totalPages: Math.ceil(total / limit) };
})
// ── GET Summary ───────────────────────────────────────────────────────────
// ── GET Summary (With BOM Cost, Settlement & Profit Breakdown) ─────────────
.get("/summary", async ({ query }) => {
const shop = (query.shop as string) || "hom-aroma";
const where = { shop };
const [orderCount, agg, itemAgg] = await Promise.all([
const [orderCount, agg, itemAgg, allOrders, settlementAgg] = await Promise.all([
prisma.tiktokOrder.count({ where }),
prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true } }),
prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true, commissionFee: true, affiliateFee: true, netSettlement: true } }),
prisma.tiktokOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, subtotalAfterDiscount: true } }),
prisma.tiktokOrder.findMany({ where, include: { items: true } }),
prisma.tiktokSettlement.aggregate({ where: { shop }, _sum: { settlementAmount: true, commissionFee: true, affiliateCommission: true, affiliatePartnerFee: true, orderFee: true, totalFee: true } }),
]);
const enrichedOrders = await enrichOrdersWithBOM(allOrders, shop);
let totalBOMCost = 0;
let ordersMissingBomCount = 0;
let ordersWithBOMCount = 0;
let totalNetProfit = 0;
for (const o of enrichedOrders) {
totalBOMCost += o.orderTotalBomCost || 0;
totalNetProfit += o.orderNetProfit || 0;
if (o.hasIncompleteBom) {
ordersMissingBomCount++;
} else {
ordersWithBOMCount++;
}
}
const totalAmount = agg._sum.orderAmount ?? 0;
const totalEstProfit = totalAmount - totalBOMCost;
const grossProfitMargin = totalAmount > 0 ? (totalEstProfit / totalAmount) * 100 : 0;
const totalCommission = settlementAgg._sum.commissionFee ?? (agg._sum.commissionFee ?? 0);
const totalAffiliate = ((settlementAgg._sum.affiliateCommission ?? 0) + (settlementAgg._sum.affiliatePartnerFee ?? 0)) || (agg._sum.affiliateFee ?? 0);
const totalSettlementAmount = settlementAgg._sum.settlementAmount ?? (agg._sum.netSettlement ?? 0);
return {
orderCount,
totalAmount: agg._sum.orderAmount ?? 0,
totalAmount,
totalShipping: agg._sum.shippingFee ?? 0,
totalRefund: agg._sum.refundAmount ?? 0,
totalTaxes: agg._sum.taxes ?? 0,
totalItems: itemAgg._sum.quantity ?? 0,
totalSkuRevenue: itemAgg._sum.subtotalAfterDiscount ?? 0,
totalBOMCost,
totalEstProfit,
grossProfitMargin,
ordersMissingBomCount,
ordersWithBOMCount,
totalCommission,
totalAffiliate,
totalSettlementAmount,
totalNetProfit,
};
})
@@ -194,28 +652,63 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
}
const allOrders = await prisma.tiktokOrder.findMany({
const rawOrders = await prisma.tiktokOrder.findMany({
where,
select: {
orderId: true, status: true, subStatus: true, createdTime: true,
orderAmount: true, shippingFee: true, refundAmount: true, taxes: true,
paymentMethod: true, province: true,
items: { select: { quantity: true, subtotalAfterDiscount: true, platformDiscount: true, sellerDiscount: true } },
include: {
items: true,
},
orderBy: { createdTime: "asc" },
});
// Enrich with BOM & Settlement
const allOrders = await enrichOrdersWithBOM(rawOrders, shop);
// Monthly aggregation
const monthMap = new Map<string, { month: string; revenue: number; orders: number; cancelled: number; items: number; platformDiscount: number; sellerDiscount: number }>();
const monthMap = new Map<string, {
month: string;
revenue: number;
orders: number;
cancelled: number;
items: number;
platformDiscount: number;
sellerDiscount: number;
commission: number;
affiliate: number;
bomCost: number;
netProfit: number;
settlement: number;
}>();
for (const o of allOrders) {
const month = (o.createdTime ?? "").slice(0, 7) || "Unknown";
if (!monthMap.has(month)) monthMap.set(month, { month, revenue: 0, orders: 0, cancelled: 0, items: 0, platformDiscount: 0, sellerDiscount: 0 });
if (!monthMap.has(month)) {
monthMap.set(month, {
month,
revenue: 0,
orders: 0,
cancelled: 0,
items: 0,
platformDiscount: 0,
sellerDiscount: 0,
commission: 0,
affiliate: 0,
bomCost: 0,
netProfit: 0,
settlement: 0,
});
}
const m = monthMap.get(month)!;
m.orders += 1;
m.revenue += o.orderAmount;
m.platformDiscount += o.items.reduce((s, i) => s + i.platformDiscount, 0);
m.sellerDiscount += o.items.reduce((s, i) => s + i.sellerDiscount, 0);
if (o.status.includes("ยกเลิก") || o.status.includes("cancel")) m.cancelled += 1;
m.items += o.items.reduce((s, i) => s + i.quantity, 0);
m.platformDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0);
m.sellerDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0);
m.commission += (o.commissionFee || 0);
m.affiliate += (o.affiliateFee || 0);
m.bomCost += (o.orderTotalBomCost || 0);
m.netProfit += (o.orderNetProfit || 0);
m.settlement += (o.netSettlement || 0);
if (o.status?.includes("ยกเลิก") || o.status?.includes("cancel")) m.cancelled += 1;
m.items += (o.items || []).reduce((s: number, i: any) => s + (i.quantity || 0), 0);
}
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
@@ -254,23 +747,46 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
// Summary
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
const totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0);
const totalPlatformDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.platformDiscount, 0);
const totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.sellerDiscount, 0);
const totalOrders = allOrders.length;
const cancelledOrders = allOrders.filter(o => o.status.includes("ยกเลิก")).length;
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
const completedOrders = totalOrders - cancelledOrders;
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
const totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0);
const totalPlatformDisc = allOrders.flatMap(o => o.items || []).reduce((s, i) => s + (i.platformDiscount || 0), 0);
const totalSellerDisc = allOrders.flatMap(o => o.items || []).reduce((s, i) => s + (i.sellerDiscount || 0), 0);
const totalCommission = allOrders.reduce((s, o) => s + (o.commissionFee || 0), 0);
const totalAffiliate = allOrders.reduce((s, o) => s + (o.affiliateFee || 0), 0);
const totalSettlementAmount = allOrders.reduce((s, o) => s + (o.netSettlement || 0), 0);
const totalBOMCost = allOrders.reduce((s, o) => s + (o.orderTotalBomCost || 0), 0);
const totalNetProfit = allOrders.reduce((s, o) => s + (o.orderNetProfit || 0), 0);
const netProfitMargin = totalRevenue > 0 ? (totalNetProfit / totalRevenue) * 100 : 0;
const totalOrders = allOrders.length;
const cancelledOrders = allOrders.filter(o => o.status?.includes("ยกเลิก")).length;
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
const completedOrders = totalOrders - cancelledOrders;
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
return {
monthly, statusBreakdown, paymentBreakdown, topProducts,
monthly,
statusBreakdown,
paymentBreakdown,
topProducts,
summary: {
totalRevenue, totalShipping, totalRefund, totalPlatformDisc, totalSellerDisc,
totalRevenue,
totalShipping,
totalRefund,
totalPlatformDisc,
totalSellerDisc,
netRevenue: totalRevenue - totalShipping,
totalOrders, completedOrders, cancelledOrders, cancelRate, aov,
totalOrders,
completedOrders,
cancelledOrders,
cancelRate,
aov,
totalCommission,
totalAffiliate,
totalSettlementAmount,
totalBOMCost,
totalNetProfit,
netProfitMargin,
},
};
})